Release Stable Build v2.2.1 (#258)

* 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

---------

Co-authored-by: TalesAmaral <57869141+TalesAmaral@users.noreply.github.com>
Co-authored-by: walti0 <95646872+walti0@users.noreply.github.com>
Co-authored-by: AMIAY <letudiantenrap.collab@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>
This commit is contained in:
Fazri Gading
2026-02-02 12:58:56 +08:00
committed by GitHub
parent 6a66ed831c
commit 1cb08f029a
42 changed files with 3605 additions and 500 deletions

View File

@@ -2,6 +2,9 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
// =============================================================================
// UUID PERSISTENCE FIX - Atomic writes, backups, validation
// =============================================================================
// Default auth domain - can be overridden by env var or config
const DEFAULT_AUTH_DOMAIN = 'auth.sanasol.ws';
@@ -49,57 +52,443 @@ function getAppDir() {
}
const CONFIG_FILE = path.join(getAppDir(), 'config.json');
const CONFIG_BACKUP = path.join(getAppDir(), 'config.json.bak');
const CONFIG_TEMP = path.join(getAppDir(), 'config.json.tmp');
// =============================================================================
// CONFIG VALIDATION
// =============================================================================
/**
* Validate config structure - ensures critical data is intact
*/
function validateConfig(config) {
if (!config || typeof config !== 'object') {
return false;
}
// If userUuids exists, it must be an object
if (config.userUuids !== undefined && typeof config.userUuids !== 'object') {
return false;
}
// If username exists, it must be a non-empty string
if (config.username !== undefined && (typeof config.username !== 'string')) {
return false;
}
return true;
}
// =============================================================================
// CONFIG LOADING - With backup recovery
// =============================================================================
/**
* Load config with automatic backup recovery
* Never returns empty object silently if data existed before
*/
function loadConfig() {
// Try primary config first
try {
if (fs.existsSync(CONFIG_FILE)) {
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
const data = fs.readFileSync(CONFIG_FILE, 'utf8');
if (data.trim()) {
const config = JSON.parse(data);
if (validateConfig(config)) {
return config;
}
console.warn('[Config] Primary config invalid structure, trying backup...');
}
}
} catch (err) {
console.log('Notice: could not load config:', err.message);
console.error('[Config] Failed to load primary config:', err.message);
}
// Try backup config
try {
if (fs.existsSync(CONFIG_BACKUP)) {
const data = fs.readFileSync(CONFIG_BACKUP, 'utf8');
if (data.trim()) {
const config = JSON.parse(data);
if (validateConfig(config)) {
console.log('[Config] Recovered from backup successfully');
// Restore primary from backup
try {
fs.writeFileSync(CONFIG_FILE, data, 'utf8');
console.log('[Config] Primary config restored from backup');
} catch (restoreErr) {
console.error('[Config] Failed to restore primary from backup:', restoreErr.message);
}
return config;
}
}
}
} catch (err) {
console.error('[Config] Failed to load backup config:', err.message);
}
// No valid config - return empty (fresh install)
console.log('[Config] No valid config found - fresh install');
return {};
}
// =============================================================================
// CONFIG SAVING - Atomic writes with backup
// =============================================================================
/**
* Save config atomically with backup
* Uses temp file + rename pattern to prevent corruption
* Creates backup before overwriting
*/
function saveConfig(update) {
try {
const configDir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
const maxRetries = 3;
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const configDir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
// Load current config
const currentConfig = loadConfig();
const newConfig = { ...currentConfig, ...update };
const data = JSON.stringify(newConfig, null, 2);
// 1. Write to temp file first
fs.writeFileSync(CONFIG_TEMP, data, 'utf8');
// 2. Verify temp file is valid JSON
const verification = JSON.parse(fs.readFileSync(CONFIG_TEMP, 'utf8'));
if (!validateConfig(verification)) {
throw new Error('Config validation failed after write');
}
// 3. Backup current config (if exists and valid)
if (fs.existsSync(CONFIG_FILE)) {
try {
const currentData = fs.readFileSync(CONFIG_FILE, 'utf8');
if (currentData.trim()) {
fs.writeFileSync(CONFIG_BACKUP, currentData, 'utf8');
}
} catch (backupErr) {
console.warn('[Config] Could not create backup:', backupErr.message);
// Continue anyway - saving new config is more important
}
}
// 4. Atomic rename (this is the critical operation)
fs.renameSync(CONFIG_TEMP, CONFIG_FILE);
return true;
} catch (err) {
lastError = err;
console.error(`[Config] Save attempt ${attempt}/${maxRetries} failed:`, err.message);
// Clean up temp file on failure
try {
if (fs.existsSync(CONFIG_TEMP)) {
fs.unlinkSync(CONFIG_TEMP);
}
} catch (cleanupErr) {
// Ignore cleanup errors
}
if (attempt < maxRetries) {
// Small delay before retry
const delay = attempt * 100;
const start = Date.now();
while (Date.now() - start < delay) {
// Busy wait (sync delay)
}
}
}
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);
}
// All retries failed - this is critical
console.error('[Config] CRITICAL: Failed to save config after all retries:', lastError.message);
throw new Error(`Failed to save config: ${lastError.message}`);
}
// =============================================================================
// USERNAME MANAGEMENT - No silent fallbacks
// =============================================================================
/**
* Save username to config
* When changing username, the UUID is preserved (rename, not new identity)
* Validates username before saving
*/
function saveUsername(username) {
saveConfig({ username: username || 'Player' });
if (!username || typeof username !== 'string') {
throw new Error('Invalid username: must be a non-empty string');
}
const newName = username.trim();
if (!newName) {
throw new Error('Invalid username: cannot be empty or whitespace');
}
if (newName.length > 16) {
throw new Error('Invalid username: must be 16 characters or less');
}
const config = loadConfig();
const currentName = config.username ? config.username.trim() : null;
const userUuids = config.userUuids || {};
// Check if we're actually changing the username (case-insensitive comparison)
const isRename = currentName && currentName.toLowerCase() !== newName.toLowerCase();
if (isRename) {
// Find the UUID for the current username
const currentKey = Object.keys(userUuids).find(
k => k.toLowerCase() === currentName.toLowerCase()
);
if (currentKey && userUuids[currentKey]) {
// Check if target username already exists (would be a different identity)
const targetKey = Object.keys(userUuids).find(
k => k.toLowerCase() === newName.toLowerCase()
);
if (targetKey) {
// Target username already exists - this is switching identity, not renaming
console.log(`[Config] Switching to existing identity: "${newName}" (UUID already exists)`);
} else {
// Rename: move UUID from old name to new name
const uuid = userUuids[currentKey];
delete userUuids[currentKey];
userUuids[newName] = uuid;
console.log(`[Config] Renamed identity: "${currentKey}" → "${newName}" (UUID preserved: ${uuid})`);
}
}
} else if (currentName && currentName !== newName) {
// Case change only - update the key to preserve the new casing
const currentKey = Object.keys(userUuids).find(
k => k.toLowerCase() === currentName.toLowerCase()
);
if (currentKey && currentKey !== newName) {
const uuid = userUuids[currentKey];
delete userUuids[currentKey];
userUuids[newName] = uuid;
console.log(`[Config] Updated username case: "${currentKey}" → "${newName}"`);
}
}
// Save both username and updated userUuids
saveConfig({ username: newName, userUuids });
console.log(`[Config] Username saved: "${newName}"`);
return newName;
}
/**
* Load username from config
* Returns null if no username set (caller must handle)
*/
function loadUsername() {
const config = loadConfig();
return config.username || 'Player';
const username = config.username;
if (username && typeof username === 'string' && username.trim()) {
return username.trim();
}
return null; // No username set - caller must handle this
}
/**
* Load username with fallback to 'Player'
* Use this only for display purposes, NOT for UUID lookup
*/
function loadUsernameWithDefault() {
return loadUsername() || 'Player';
}
/**
* Check if username is configured
*/
function hasUsername() {
return loadUsername() !== null;
}
// =============================================================================
// UUID MANAGEMENT - Persistent and safe
// =============================================================================
/**
* Normalize username for UUID lookup (case-insensitive, trimmed)
*/
function normalizeUsername(username) {
if (!username || typeof username !== 'string') return null;
return username.trim().toLowerCase();
}
/**
* Get UUID for a username
* Creates new UUID only if user explicitly doesn't exist
* Uses case-insensitive lookup to prevent duplicates, but preserves original case for display
*/
function getUuidForUser(username) {
const { v4: uuidv4 } = require('uuid');
if (!username || typeof username !== 'string' || !username.trim()) {
throw new Error('Cannot get UUID: username is required');
}
const displayName = username.trim();
const normalizedLookup = displayName.toLowerCase();
const config = loadConfig();
const userUuids = config.userUuids || {};
if (userUuids[username]) {
return userUuids[username];
// Case-insensitive lookup - find existing key regardless of case
const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
if (existingKey) {
// Found existing - return UUID, update display name if case changed
const existingUuid = userUuids[existingKey];
// If user typed different case, update the key to new case (preserving UUID)
if (existingKey !== displayName) {
console.log(`[Config] Updating username case: "${existingKey}" → "${displayName}"`);
delete userUuids[existingKey];
userUuids[displayName] = existingUuid;
saveConfig({ userUuids });
}
return existingUuid;
}
// Create new UUID for new user - store with original case
const newUuid = uuidv4();
userUuids[username] = newUuid;
userUuids[displayName] = newUuid;
saveConfig({ userUuids });
console.log(`[Config] Created new UUID for "${displayName}": ${newUuid}`);
return newUuid;
}
/**
* Get current user's UUID (based on saved username)
*/
function getCurrentUuid() {
const username = loadUsername();
if (!username) {
throw new Error('Cannot get current UUID: no username configured');
}
return getUuidForUser(username);
}
/**
* Get all UUID mappings (raw object)
*/
function getAllUuidMappings() {
const config = loadConfig();
return config.userUuids || {};
}
/**
* Get all UUID mappings as array with current user flag
*/
function getAllUuidMappingsArray() {
const config = loadConfig();
const userUuids = config.userUuids || {};
const currentUsername = loadUsername();
// Case-insensitive comparison for isCurrent
const normalizedCurrent = currentUsername ? currentUsername.toLowerCase() : null;
return Object.entries(userUuids).map(([username, uuid]) => ({
username, // Original case preserved
uuid,
isCurrent: username.toLowerCase() === normalizedCurrent
}));
}
/**
* Set UUID for a specific user
* Validates UUID format before saving
* Preserves original case of username
*/
function setUuidForUser(username, uuid) {
const { validate: validateUuid } = require('uuid');
if (!username || typeof username !== 'string' || !username.trim()) {
throw new Error('Invalid username');
}
if (!validateUuid(uuid)) {
throw new Error('Invalid UUID format');
}
const displayName = username.trim();
const normalizedLookup = displayName.toLowerCase();
const config = loadConfig();
const userUuids = config.userUuids || {};
// Remove any existing entry with same name (case-insensitive)
const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
if (existingKey) {
delete userUuids[existingKey];
}
// Store with original case
userUuids[displayName] = uuid;
saveConfig({ userUuids });
console.log(`[Config] UUID set for "${displayName}": ${uuid}`);
return uuid;
}
/**
* Generate a new UUID (without saving)
*/
function generateNewUuid() {
const { v4: uuidv4 } = require('uuid');
return uuidv4();
}
/**
* Delete UUID for a specific user
* Uses case-insensitive lookup
*/
function deleteUuidForUser(username) {
if (!username || typeof username !== 'string') {
throw new Error('Invalid username');
}
const normalizedLookup = username.trim().toLowerCase();
const config = loadConfig();
const userUuids = config.userUuids || {};
// Case-insensitive lookup
const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
if (existingKey) {
delete userUuids[existingKey];
saveConfig({ userUuids });
console.log(`[Config] UUID deleted for "${username}"`);
return true;
}
return false;
}
/**
* Reset current user's UUID (generates new one)
*/
function resetCurrentUserUuid() {
const username = loadUsername();
if (!username) {
throw new Error('Cannot reset UUID: no username configured');
}
const { v4: uuidv4 } = require('uuid');
const newUuid = uuidv4();
return setUuidForUser(username, newUuid);
}
// =============================================================================
// JAVA PATH MANAGEMENT
// =============================================================================
function saveJavaPath(javaPath) {
const trimmed = (javaPath || '').trim();
saveConfig({ javaPath: trimmed });
@@ -120,6 +509,10 @@ function loadJavaPath() {
return config.javaPath || '';
}
// =============================================================================
// INSTALL PATH MANAGEMENT
// =============================================================================
function saveInstallPath(installPath) {
const trimmed = (installPath || '').trim();
saveConfig({ installPath: trimmed });
@@ -130,6 +523,10 @@ function loadInstallPath() {
return config.installPath || '';
}
// =============================================================================
// DISCORD RPC SETTINGS
// =============================================================================
function saveDiscordRPC(enabled) {
saveConfig({ discordRPC: !!enabled });
}
@@ -139,6 +536,10 @@ function loadDiscordRPC() {
return config.discordRPC !== undefined ? config.discordRPC : true;
}
// =============================================================================
// LANGUAGE SETTINGS
// =============================================================================
function saveLanguage(language) {
saveConfig({ language: language || 'en' });
}
@@ -148,6 +549,10 @@ function loadLanguage() {
return config.language || 'en';
}
// =============================================================================
// LAUNCHER SETTINGS
// =============================================================================
function saveCloseLauncherOnStart(enabled) {
saveConfig({ closeLauncherOnStart: !!enabled });
}
@@ -166,31 +571,38 @@ function loadLauncherHardwareAcceleration() {
return config.launcherHardwareAcceleration !== undefined ? config.launcherHardwareAcceleration : true;
}
// =============================================================================
// MODS MANAGEMENT
// =============================================================================
function saveModsToConfig(mods) {
try {
const config = loadConfig();
// Config migration handles structure, but mod saves must go to the ACTIVE profile.
// Global installedMods is kept mainly for reference/migration.
// The profile is the source of truth for enabled mods.
if (config.activeProfileId && config.profiles && config.profiles[config.activeProfileId]) {
config.profiles[config.activeProfileId].mods = mods;
} else {
// Fallback for legacy or no-profile state
config.installedMods = mods;
}
// Use atomic save
const configDir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
console.log('Mods saved to config.json');
// Write atomically
const data = JSON.stringify(config, null, 2);
fs.writeFileSync(CONFIG_TEMP, data, 'utf8');
if (fs.existsSync(CONFIG_FILE)) {
fs.copyFileSync(CONFIG_FILE, CONFIG_BACKUP);
}
fs.renameSync(CONFIG_TEMP, CONFIG_FILE);
console.log('[Config] Mods saved successfully');
} catch (error) {
console.error('Error saving mods to config:', error);
console.error('[Config] Error saving mods:', error);
throw error;
}
}
@@ -198,25 +610,34 @@ function loadModsFromConfig() {
try {
const config = loadConfig();
// Prefer Active Profile
if (config.activeProfileId && config.profiles && config.profiles[config.activeProfileId]) {
return config.profiles[config.activeProfileId].mods || [];
}
return config.installedMods || [];
} catch (error) {
console.error('Error loading mods from config:', error);
console.error('[Config] Error loading mods:', error);
return [];
}
}
// =============================================================================
// FIRST LAUNCH DETECTION - FIXED
// =============================================================================
/**
* Check if this is the first launch
* FIXED: Was always returning true due to bug
*/
function isFirstLaunch() {
const config = loadConfig();
// If explicitly marked, use that
if ('hasLaunchedBefore' in config) {
return !config.hasLaunchedBefore;
}
// Check for any existing user data
const hasUserData = config.installPath || config.username || config.javaPath ||
config.chatUsername || config.userUuids ||
Object.keys(config).length > 0;
@@ -225,65 +646,17 @@ function isFirstLaunch() {
return true;
}
return true;
// FIXED: Was returning true here, should be false
return false;
}
function markAsLaunched() {
saveConfig({ hasLaunchedBefore: true, firstLaunchDate: new Date().toISOString() });
}
// UUID Management Functions
function getCurrentUuid() {
const username = loadUsername();
return getUuidForUser(username);
}
function getAllUuidMappings() {
const config = loadConfig();
return config.userUuids || {};
}
function setUuidForUser(username, uuid) {
const { v4: uuidv4, validate: validateUuid } = require('uuid');
// Validate UUID format
if (!validateUuid(uuid)) {
throw new Error('Invalid UUID format');
}
const config = loadConfig();
const userUuids = config.userUuids || {};
userUuids[username] = uuid;
saveConfig({ userUuids });
return uuid;
}
function generateNewUuid() {
const { v4: uuidv4 } = require('uuid');
return uuidv4();
}
function deleteUuidForUser(username) {
const config = loadConfig();
const userUuids = config.userUuids || {};
if (userUuids[username]) {
delete userUuids[username];
saveConfig({ userUuids });
return true;
}
return false;
}
function resetCurrentUserUuid() {
const username = loadUsername();
const { v4: uuidv4 } = require('uuid');
const newUuid = uuidv4();
return setUuidForUser(username, newUuid);
}
// =============================================================================
// GPU PREFERENCE
// =============================================================================
function saveGpuPreference(gpuPreference) {
saveConfig({ gpuPreference: gpuPreference || 'auto' });
@@ -294,6 +667,10 @@ function loadGpuPreference() {
return config.gpuPreference || 'auto';
}
// =============================================================================
// VERSION MANAGEMENT
// =============================================================================
function saveVersionClient(versionClient) {
saveConfig({ version_client: versionClient });
}
@@ -306,7 +683,7 @@ function loadVersionClient() {
function saveVersionBranch(versionBranch) {
const branch = versionBranch || 'release';
if (branch !== 'release' && branch !== 'pre-release') {
console.warn(`Invalid branch "${branch}", defaulting to "release"`);
console.warn(`[Config] Invalid branch "${branch}", defaulting to "release"`);
saveConfig({ version_branch: 'release' });
} else {
saveConfig({ version_branch: branch });
@@ -318,50 +695,98 @@ function loadVersionBranch() {
return config.version_branch || 'release';
}
// =============================================================================
// READY STATE - For UI to check before allowing launch
// =============================================================================
/**
* Check if launcher is ready to launch game
* Returns object with ready state and any issues
*/
function checkLaunchReady() {
const issues = [];
const username = loadUsername();
if (!username) {
issues.push('No username configured');
} else if (username === 'Player') {
issues.push('Using default username "Player"');
}
return {
ready: issues.length === 0,
hasUsername: !!username,
username: username,
issues: issues
};
}
// =============================================================================
// EXPORTS
// =============================================================================
module.exports = {
// Core config
loadConfig,
saveConfig,
validateConfig,
// Username (no silent fallbacks)
saveUsername,
loadUsername,
loadUsernameWithDefault,
hasUsername,
// UUID management
getUuidForUser,
saveJavaPath,
loadJavaPath,
saveInstallPath,
loadInstallPath,
saveDiscordRPC,
loadDiscordRPC,
saveLanguage,
loadLanguage,
saveModsToConfig,
loadModsFromConfig,
isFirstLaunch,
markAsLaunched,
CONFIG_FILE,
// Auth server exports
getAuthServerUrl,
getAuthDomain,
saveAuthDomain,
// UUID Management exports
getCurrentUuid,
getAllUuidMappings,
getAllUuidMappingsArray,
setUuidForUser,
generateNewUuid,
deleteUuidForUser,
resetCurrentUserUuid,
// GPU Preference exports
saveGpuPreference,
loadGpuPreference,
// Close Launcher export
// Java/Install paths
saveJavaPath,
loadJavaPath,
saveInstallPath,
loadInstallPath,
// Settings
saveDiscordRPC,
loadDiscordRPC,
saveLanguage,
loadLanguage,
saveCloseLauncherOnStart,
loadCloseLauncherOnStart,
// Hardware Acceleration functions
saveLauncherHardwareAcceleration,
loadLauncherHardwareAcceleration,
// Version Management exports
// Mods
saveModsToConfig,
loadModsFromConfig,
// Launch state
isFirstLaunch,
markAsLaunched,
checkLaunchReady,
// Auth server
getAuthServerUrl,
getAuthDomain,
saveAuthDomain,
// GPU
saveGpuPreference,
loadGpuPreference,
// Version
saveVersionClient,
loadVersionClient,
saveVersionBranch,
loadVersionBranch
loadVersionBranch,
// Constants
CONFIG_FILE
};

View File

@@ -5,6 +5,8 @@
const {
saveUsername,
loadUsername,
loadUsernameWithDefault,
hasUsername,
saveJavaPath,
loadJavaPath,
saveInstallPath,
@@ -27,9 +29,11 @@ const {
getUuidForUser,
isFirstLaunch,
markAsLaunched,
checkLaunchReady,
// UUID Management
getCurrentUuid,
getAllUuidMappings,
getAllUuidMappingsArray,
setUuidForUser,
generateNewUuid,
deleteUuidForUser,
@@ -110,7 +114,10 @@ module.exports = {
// User configuration functions
saveUsername,
loadUsername,
loadUsernameWithDefault,
hasUsername,
getUuidForUser,
checkLaunchReady,
// Java configuration functions
saveJavaPath,
@@ -162,6 +169,7 @@ module.exports = {
// UUID Management functions
getCurrentUuid,
getAllUuidMappings,
getAllUuidMappingsArray,
setUuidForUser,
generateNewUuid,
deleteUuidForUser,

View File

@@ -7,7 +7,19 @@ 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 {
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');
@@ -104,8 +116,56 @@ function generateLocalTokens(uuid, name) {
};
}
async function launchGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
// Synchronize server list on every game launch
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();
@@ -113,11 +173,14 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
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();
@@ -128,7 +191,10 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
throw new Error('Game is not installed. Please install the game first.');
}
saveUsername(playerName);
// 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);
}
@@ -287,14 +353,12 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
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);
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') {
@@ -304,9 +368,9 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
// 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
'/usr/lib64/libzstd.so.1' // Fedora/RHEL
'/usr/lib/x86_64-linux-gnu/libzstd.so.1' // Debian/Ubuntu
];
let systemLibzstd = null;
@@ -358,23 +422,35 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
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;
child.stdout.on('data', (data) => {
outputReceived = true;
console.log(`Game output: ${data.toString().trim()}`);
});
if (child.stdout) {
child.stdout.on('data', (data) => {
outputReceived = true;
const msg = data.toString().trim();
console.log(`Game output: ${msg}`);
});
}
child.stderr.on('data', (data) => {
outputReceived = true;
console.error(`Game error: ${data.toString().trim()}`);
});
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);
@@ -383,30 +459,30 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
child.on('exit', (code, signal) => {
hasExited = true;
clearTimeout(launchCheckTimeout);
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);
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}`);
}
});
// 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);
// 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, don't wait for setTimeout
// Return immediately after spawn
return { success: true, installed: true, launched: true, pid: child.pid };
} catch (spawnError) {
console.error(`Error spawning game process: ${spawnError.message}`);
@@ -417,10 +493,26 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
}
}
async function launchGameWithVersionCheck(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
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);
}
@@ -474,7 +566,7 @@ async function launchGameWithVersionCheck(playerName = 'Player', progressCallbac
progressCallback('Launching game...', 80, null, null, null);
}
const launchResult = await launchGame(playerName, progressCallback, javaPathOverride, installPathOverride, gpuPreference, branch);
const launchResult = await launchGame(playerNameOverride, progressCallback, javaPathOverride, installPathOverride, gpuPreference, branch);
// Ensure we always return a result
if (!launchResult) {

View File

@@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const { execFile, exec } = require('child_process');
const { promisify } = require('util');
const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursive, GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
const { getOS, getArch } = require('../utils/platformUtils');
const { downloadFile, retryDownload, retryStalledDownload, MAX_AUTOMATIC_STALL_RETRIES } = require('../utils/fileManager');
@@ -11,6 +12,57 @@ const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFi
const { saveUsername, saveInstallPath, loadJavaPath, CONFIG_FILE, loadConfig, loadVersionBranch, saveVersionClient, loadVersionClient } = require('../core/config');
const { resolveJavaPath, detectSystemJava, downloadJRE, getJavaExec, getBundledJavaPath } = require('./javaManager');
const { getUserDataPath, migrateUserDataToCentralized } = require('../utils/userDataMigration');
const userDataBackup = require('../utils/userDataBackup');
const execAsync = promisify(exec);
// Helper function to check if game processes are running
async function isGameRunning() {
try {
let command;
if (process.platform === 'win32') {
// On Windows, check for HytaleClient.exe processes
command = 'tasklist /FI "IMAGENAME eq HytaleClient.exe" /NH';
} else if (process.platform === 'darwin') {
// On macOS, check for HytaleClient processes
command = 'pgrep -f HytaleClient';
} else {
// On Linux, check for HytaleClient processes
command = 'pgrep -f HytaleClient';
}
const { stdout } = await execAsync(command);
return stdout.trim().length > 0;
} catch (error) {
// If command fails, assume no processes are running
return false;
}
}
// Helper function to safely remove directory with retry logic
async function safeRemoveDirectory(dirPath, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
if (fs.existsSync(dirPath)) {
fs.rmSync(dirPath, { recursive: true, force: true });
console.log(`Successfully removed directory: ${dirPath}`);
}
return; // Success, exit the loop
} catch (error) {
console.warn(`Attempt ${attempt}/${maxRetries} failed to remove ${dirPath}: ${error.message}`);
if (attempt < maxRetries) {
// Wait before retrying (exponential backoff)
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
console.log(`Waiting ${delay}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, delay));
} else {
// Last attempt failed, throw the error
throw new Error(`Failed to remove directory ${dirPath} after ${maxRetries} attempts: ${error.message}`);
}
}
}
}
async function downloadPWR(branch = 'release', fileName = '7.pwr', progressCallback, cacheDir = CACHE_DIR, manualRetry = false) {
const osName = getOS();
@@ -472,7 +524,9 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver
}
});
saveUsername(playerName);
// NOTE: Do NOT save username here - username should only be saved when user explicitly
// changes it in Settings. Saving here could overwrite a good username with 'Player' default.
// The username is only needed for launching, not for installing.
if (installPathOverride) {
saveInstallPath(installPathOverride);
}
@@ -587,8 +641,14 @@ async function uninstallGame() {
throw new Error('Game is not installed');
}
// Check if game is running before attempting to delete files
const gameRunning = await isGameRunning();
if (gameRunning) {
throw new Error('Cannot uninstall game while it is running. Please close the game first.');
}
try {
fs.rmSync(appDir, { recursive: true, force: true });
await safeRemoveDirectory(appDir);
console.log('Game uninstalled successfully - removed entire HytaleF2P folder');
if (fs.existsSync(CONFIG_FILE)) {
@@ -670,14 +730,31 @@ async function repairGame(progressCallback, branchOverride = null) {
progressCallback('Removing old game files...', 30, null, null, null);
}
// Delete Game and Cache Directory
// Check if game is running before attempting to delete files
const gameRunning = await isGameRunning();
if (gameRunning) {
console.warn('[RepairGame] Game appears to be running. This may cause permission errors during repair.');
console.log('[RepairGame] Please close the game before repairing, or wait for the repair to complete.');
}
// Delete Game and Cache Directory with retry logic
console.log('Removing corrupted game files...');
fs.rmSync(gameDir, { recursive: true, force: true });
try {
await safeRemoveDirectory(gameDir);
} catch (error) {
console.error(`[RepairGame] Failed to remove game directory: ${error.message}`);
throw new Error(`Cannot repair game: ${error.message}. Please ensure the game is not running and try again.`);
}
const cacheDir = path.join(appDir, 'cache');
if (fs.existsSync(cacheDir)) {
console.log('Clearing cache directory...');
fs.rmSync(cacheDir, { recursive: true, force: true });
try {
await safeRemoveDirectory(cacheDir);
} catch (error) {
console.warn(`[RepairGame] Failed to clear cache directory: ${error.message}`);
// Don't throw here, cache cleanup is not critical
}
}
console.log('Reinstalling game files...');

View File

@@ -340,36 +340,70 @@ async function extractJRE(archivePath, destDir) {
}
function extractZip(zipPath, dest) {
const zip = new AdmZip(zipPath);
const entries = zip.getEntries();
try {
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}`);
}
for (const entry of entries) {
const entryPath = path.join(dest, entry.entryName);
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);
// Security check: prevent zip slip attacks
const resolvedPath = path.resolve(entryPath);
const resolvedDest = path.resolve(dest);
if (!resolvedPath.startsWith(resolvedDest)) {
throw new Error(`Invalid file path detected: ${entryPath}`);
}
try {
if (entry.isDirectory) {
fs.mkdirSync(entryPath, { recursive: true });
} else {
// Ensure parent directory exists
const parentDir = path.dirname(entryPath);
fs.mkdirSync(parentDir, { recursive: true });
// Get file data and write it
const data = entry.getData();
if (!data) {
console.warn(`Warning: No data for file ${entry.entryName}, skipping`);
continue;
}
fs.writeFileSync(entryPath, data);
// Set permissions on non-Windows platforms
if (process.platform !== 'win32') {
try {
const mode = entry.header.attr >>> 16;
if (mode > 0) {
fs.chmodSync(entryPath, mode);
}
} catch (chmodError) {
console.warn(`Warning: Could not set permissions for ${entryPath}: ${chmodError.message}`);
}
}
}
} catch (entryError) {
console.error(`Error extracting ${entry.entryName}: ${entryError.message}`);
// Continue with other entries rather than failing completely
continue;
}
}
} catch (error) {
throw new Error(`Failed to extract ZIP archive: ${error.message}`);
}
}
function extractTarGz(tarGzPath, dest) {
return tar.extract({
file: tarGzPath,
cwd: dest,
strip: 0
});
try {
return tar.extract({
file: tarGzPath,
cwd: dest,
strip: 0
});
} catch (error) {
throw new Error(`Failed to extract TAR.GZ archive: ${error.message}`);
}
}
function flattenJREDir(jreLatest) {

View File

@@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const { downloadFile, findHomePageUIPath, findLogoPath } = require('../utils/fileManager');
const { smartRequest } = require('../utils/proxyClient');
async function downloadAndReplaceHomePageUI(gameDir, progressCallback) {
try {
@@ -13,7 +14,8 @@ async function downloadAndReplaceHomePageUI(gameDir, progressCallback) {
const homeUIUrl = 'https://files.hytalef2p.com/api/HomeUI';
const tempHomePath = path.join(path.dirname(gameDir), 'HomePage_temp.ui');
await downloadFile(homeUIUrl, tempHomePath);
const response = await smartRequest(homeUIUrl, { responseType: 'arraybuffer' });
fs.writeFileSync(tempHomePath, response.data);
const existingHomePath = findHomePageUIPath(gameDir);
@@ -66,7 +68,8 @@ async function downloadAndReplaceLogo(gameDir, progressCallback) {
const logoUrl = 'https://files.hytalef2p.com/api/Logo';
const tempLogoPath = path.join(path.dirname(gameDir), 'Logo@2x_temp.png');
await downloadFile(logoUrl, tempLogoPath);
const response = await smartRequest(logoUrl, { responseType: 'arraybuffer' });
fs.writeFileSync(tempLogoPath, response.data);
const existingLogoPath = findLogoPath(gameDir);

View File

@@ -3,32 +3,117 @@ const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { PLAYER_ID_FILE, APP_DIR } = require('../core/paths');
function getOrCreatePlayerId() {
try {
if (!fs.existsSync(APP_DIR)) {
fs.mkdirSync(APP_DIR, { recursive: true });
}
/**
* DEPRECATED: This file is kept for backward compatibility.
*
* The primary UUID system is now in config.js using userUuids.
* This player_id.json system was a separate UUID storage that could
* cause desync issues.
*
* New code should use config.js functions:
* - getUuidForUser(username) - Get/create UUID for a username
* - getCurrentUuid() - Get current user's UUID
* - setUuidForUser(username, uuid) - Set UUID for a user
*
* This function is kept for migration purposes only.
*/
if (fs.existsSync(PLAYER_ID_FILE)) {
const data = JSON.parse(fs.readFileSync(PLAYER_ID_FILE, 'utf8'));
if (data.playerId) {
return data.playerId;
/**
* Get or create a legacy player ID
* NOTE: This is DEPRECATED - use config.js getUuidForUser() instead
*
* FIXED: No longer returns random UUID on error - throws instead
*/
function getOrCreatePlayerId() {
const maxRetries = 3;
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
if (!fs.existsSync(APP_DIR)) {
fs.mkdirSync(APP_DIR, { recursive: true });
}
if (fs.existsSync(PLAYER_ID_FILE)) {
const data = fs.readFileSync(PLAYER_ID_FILE, 'utf8');
if (data.trim()) {
const parsed = JSON.parse(data);
if (parsed.playerId) {
return parsed.playerId;
}
}
}
// No existing ID - create new one atomically
const newPlayerId = uuidv4();
const tempFile = PLAYER_ID_FILE + '.tmp';
const playerData = {
playerId: newPlayerId,
createdAt: new Date().toISOString(),
note: 'DEPRECATED: This file is for legacy compatibility. UUID is now stored in config.json userUuids.'
};
// Write to temp file first
fs.writeFileSync(tempFile, JSON.stringify(playerData, null, 2));
// Atomic rename
fs.renameSync(tempFile, PLAYER_ID_FILE);
console.log(`[PlayerManager] Created new legacy player ID: ${newPlayerId}`);
return newPlayerId;
} catch (error) {
lastError = error;
console.error(`[PlayerManager] Attempt ${attempt}/${maxRetries} failed:`, error.message);
if (attempt < maxRetries) {
// Small delay before retry
const delay = attempt * 100;
const start = Date.now();
while (Date.now() - start < delay) {
// Busy wait
}
}
}
}
const newPlayerId = uuidv4();
fs.writeFileSync(PLAYER_ID_FILE, JSON.stringify({
playerId: newPlayerId,
createdAt: new Date().toISOString()
}, null, 2));
// FIXED: Do NOT return random UUID - throw error instead
// Returning random UUID was causing silent identity loss
console.error('[PlayerManager] CRITICAL: Failed to get/create player ID after all retries');
throw new Error(`Failed to manage player ID: ${lastError.message}`);
}
return newPlayerId;
/**
* Migrate legacy player_id.json to config.json userUuids
* Call this during app startup
*/
function migrateLegacyPlayerId() {
try {
if (!fs.existsSync(PLAYER_ID_FILE)) {
return null; // No legacy file to migrate
}
const data = JSON.parse(fs.readFileSync(PLAYER_ID_FILE, 'utf8'));
if (!data.playerId) {
return null;
}
console.log(`[PlayerManager] Found legacy player_id.json with ID: ${data.playerId}`);
// Mark file as migrated by renaming
const migratedFile = PLAYER_ID_FILE + '.migrated';
if (!fs.existsSync(migratedFile)) {
fs.renameSync(PLAYER_ID_FILE, migratedFile);
console.log('[PlayerManager] Legacy player_id.json marked as migrated');
}
return data.playerId;
} catch (error) {
console.error('Error managing player ID:', error);
return uuidv4();
console.error('[PlayerManager] Error during legacy migration:', error.message);
return null;
}
}
module.exports = {
getOrCreatePlayerId
getOrCreatePlayerId,
migrateLegacyPlayerId
};

View File

@@ -2,6 +2,7 @@ const axios = require('axios');
const crypto = require('crypto');
const fs = require('fs');
const { getOS, getArch } = require('../utils/platformUtils');
const { smartRequest } = require('../utils/proxyClient');
const BASE_PATCH_URL = 'https://game-patches.hytale.com/patches';
const MANIFEST_API = 'https://files.hytalef2p.com/api/patch_manifest';
@@ -9,8 +10,7 @@ const MANIFEST_API = 'https://files.hytalef2p.com/api/patch_manifest';
async function getLatestClientVersion(branch = 'release') {
try {
console.log(`Fetching latest client version from API (branch: ${branch})...`);
const response = await axios.get('https://files.hytalef2p.com/api/version_client', {
params: { branch },
const response = await smartRequest(`https://files.hytalef2p.com/api/version_client?branch=${branch}`, {
timeout: 40000,
headers: {
'User-Agent': 'Hytale-F2P-Launcher'
@@ -66,8 +66,7 @@ async function fetchPatchManifest(branch = 'release') {
try {
const os = getOS();
const arch = getArch();
const response = await axios.get(MANIFEST_API, {
params: { branch, os, arch },
const response = await smartRequest(`${MANIFEST_API}?branch=${branch}&os=${os}&arch=${arch}`, {
timeout: 10000
});
return response.data.patches || {};

View File

@@ -1,5 +1,6 @@
const fs = require('fs');
const path = require('path');
const { smartDownloadStream } = require('./proxyClient');
// Domain configuration
const ORIGINAL_DOMAIN = 'hytale.com';
@@ -605,9 +606,6 @@ class ClientPatcher {
console.log('Downloading pre-patched HytaleServer.jar...');
try {
const https = require('https');
// Use different URL for pre-release vs release
let url;
if (branch === 'pre-release') {
url = 'https://patcher.authbp.xyz/download/patched_prerelease';
@@ -617,41 +615,28 @@ class ClientPatcher {
console.log(' Using release patched server from:', url);
}
const file = fs.createWriteStream(serverPath);
let totalSize = 0;
let downloaded = 0;
const stream = await smartDownloadStream(url, (chunk, downloadedBytes, total) => {
downloaded = downloadedBytes;
totalSize = total;
if (progressCallback && totalSize) {
const percent = 30 + Math.floor((downloaded / totalSize) * 60);
progressCallback(`Downloading... ${(downloaded / 1024 / 1024).toFixed(2)} MB`, percent);
}
});
stream.pipe(file);
await new Promise((resolve, reject) => {
const handleResponse = (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
https.get(response.headers.location, handleResponse).on('error', reject);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
return;
}
const file = fs.createWriteStream(serverPath);
const totalSize = parseInt(response.headers['content-length'], 10);
let downloaded = 0;
response.on('data', (chunk) => {
downloaded += chunk.length;
if (progressCallback && totalSize) {
const percent = 30 + Math.floor((downloaded / totalSize) * 60);
progressCallback(`Downloading... ${(downloaded / 1024 / 1024).toFixed(2)} MB`, percent);
}
});
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
};
https.get(url, handleResponse).on('error', (err) => {
fs.unlink(serverPath, () => {});
reject(err);
file.on('finish', () => {
file.close();
resolve();
});
file.on('error', reject);
stream.on('error', reject);
});
console.log(' Download successful');

View File

@@ -1,4 +1,4 @@
const { execSync } = require('child_process');
const { execSync, spawnSync } = require('child_process');
const fs = require('fs');
function getOS() {
@@ -116,117 +116,454 @@ function detectGpu() {
}
function detectGpuLinux() {
const output = execSync('lspci -nn | grep \'VGA\\|3D\'', { encoding: 'utf8' });
let output = '';
try {
output = execSync('lspci -nn | grep -E "VGA|3D"', { encoding: 'utf8' });
} catch (e) {
return { mode: 'integrated', vendor: 'intel', integratedName: 'Unknown', dedicatedName: null };
}
const lines = output.split('\n').filter(line => line.trim());
let integratedName = null;
let dedicatedName = null;
let hasNvidia = false;
let hasAmd = false;
let gpus = {
integrated: [],
dedicated: []
};
for (const line of lines) {
if (line.includes('VGA') || line.includes('3D')) {
const match = line.match(/\[([^\]]+)\]/g);
let modelName = null;
if (match && match.length >= 2) {
modelName = match[1].slice(1, -1);
// Example: 01:00.0 VGA compatible controller [0300]: NVIDIA Corporation TU116 [GeForce GTX 1660 Ti] [10de:2182] (rev a1)
// Matches all content inside [...]
const brackets = line.match(/\[([^\]]+)\]/g);
let name = line; // fallback
let vendorId = '';
if (brackets && brackets.length >= 2) {
const idBracket = brackets.find(b => b.includes(':')); // [10de:2182]
if (idBracket) {
vendorId = idBracket.replace(/[\[\]]/g, '').split(':')[0].toLowerCase();
// The bracket before the ID bracket is usually the model name.
const idIndex = brackets.indexOf(idBracket);
if (idIndex > 0) {
name = brackets[idIndex - 1].replace(/[\[\]]/g, '');
}
}
} else if (brackets && brackets.length === 1) {
name = brackets[0].replace(/[\[\]]/g, '');
}
if (line.includes('10de:') || line.toLowerCase().includes('nvidia')) {
hasNvidia = true;
dedicatedName = "NVIDIA " + modelName || 'NVIDIA GPU';
console.log('Detected NVIDIA GPU:', dedicatedName);
} else if (line.includes('1002:') || line.toLowerCase().includes('amd') || line.toLowerCase().includes('radeon')) {
hasAmd = true;
dedicatedName = "AMD " + modelName || 'AMD GPU';
console.log('Detected AMD GPU:', dedicatedName);
} else if (line.includes('8086:') || line.toLowerCase().includes('intel')) {
integratedName = "Intel " + modelName || 'Intel GPU';
console.log('Detected Intel GPU:', integratedName);
// Clean name
name = name.trim();
const lowerName = name.toLowerCase();
const lowerLine = line.toLowerCase();
// Vendor detection
const isNvidia = lowerLine.includes('nvidia') || vendorId === '10de';
const isAmd = lowerLine.includes('amd') || lowerLine.includes('radeon') || vendorId === '1002';
const isIntel = lowerLine.includes('intel') || vendorId === '8086';
// Intel Arc detection
const isIntelArc = isIntel && (lowerName.includes('arc') || lowerName.includes('a770') || lowerName.includes('a750') || lowerName.includes('a380'));
let vendor = 'unknown';
if (isNvidia) vendor = 'nvidia';
else if (isAmd) vendor = 'amd';
else if (isIntel) vendor = 'intel';
let vramMb = 0;
// VRAM Detection Logic
if (isNvidia) {
try {
// Try nvidia-smi
const smiOutput = execSync('nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
const vramVal = parseInt(smiOutput.split('\n')[0]); // Take first if multiple
if (!isNaN(vramVal)) {
vramMb = vramVal;
}
} catch (err) {
// failed
}
} else if (isAmd) {
// Try /sys/class/drm/card*/device/mem_info_vram_total
// This is a bit heuristical, we need to match the card.
// But usually checking any card with AMD vendor in /sys is a good guess if we just want "the AMD GPU vram".
try {
const cards = fs.readdirSync('/sys/class/drm').filter(c => c.startsWith('card') && !c.includes('-'));
for (const card of cards) {
try {
const vendorFile = fs.readFileSync(`/sys/class/drm/${card}/device/vendor`, 'utf8').trim();
if (vendorFile === '0x1002') { // AMD vendor ID
const vramBytes = fs.readFileSync(`/sys/class/drm/${card}/device/mem_info_vram_total`, 'utf8').trim();
vramMb = Math.round(parseInt(vramBytes) / (1024 * 1024));
if (vramMb > 0) break;
}
} catch (e2) {}
}
} catch (err) {}
} else if (isIntel) {
// Try lspci -v to get prefetchable memory (stolen/dedicated aperture)
try {
// Extract slot from line, e.g. "00:02.0"
const slot = line.split(' ')[0];
if (slot && /^[0-9a-f:.]+$/.test(slot)) {
const verbose = execSync(`lspci -v -s ${slot}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
const vLines = verbose.split('\n');
for (const vLine of vLines) {
// Match "Memory at ... (..., prefetchable) [size=256M]"
// Must ensure it is prefetchable and NOT non-prefetchable
if (vLine.includes('prefetchable') && !vLine.includes('non-prefetchable')) {
const match = vLine.match(/size=([0-9]+)([KMGT])/);
if (match) {
let size = parseInt(match[1]);
const unit = match[2];
if (unit === 'G') size *= 1024;
else if (unit === 'K') size /= 1024;
// M is default
if (size > 0) {
vramMb = size;
break;
}
}
}
}
}
} catch (e) {
// ignore
}
}
const gpuInfo = {
name: name,
vendor: vendor,
vram: vramMb
};
if (isNvidia || isAmd || isIntelArc) {
gpus.dedicated.push(gpuInfo);
} else if (isIntel) {
gpus.integrated.push(gpuInfo);
} else {
// Unknown vendor or other, fallback to integrated list to be safe
gpus.integrated.push(gpuInfo);
}
}
if (hasNvidia) {
return { mode: 'dedicated', vendor: 'nvidia', integratedName: integratedName || 'Intel GPU', dedicatedName };
} else if (hasAmd) {
return { mode: 'dedicated', vendor: 'amd', integratedName: integratedName || 'Intel GPU', dedicatedName };
} else {
return { mode: 'integrated', vendor: 'intel', integratedName: integratedName || 'Intel GPU', dedicatedName: null };
// Fallback: Attempt to get Integrated VRAM via glxinfo if it's STILL 0 (common for Intel iGPUs if lspci failed)
// glxinfo -B usually reports the active renderer's "Video memory" which includes shared memory for iGPUs.
if (gpus.integrated.length > 0 && gpus.integrated[0].vram === 0) {
try {
const glxOut = execSync('glxinfo -B', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
const lines = glxOut.split('\n');
let glxVendor = '';
let glxMem = 0;
for (const line of lines) {
const trim = line.trim();
if (trim.startsWith('Device:')) {
const lower = trim.toLowerCase();
if (lower.includes('intel')) glxVendor = 'intel';
else if (lower.includes('nvidia')) glxVendor = 'nvidia';
else if (lower.includes('amd') || lower.includes('ati')) glxVendor = 'amd';
} else if (trim.startsWith('Video memory:')) {
// Example: "Video memory: 15861MB"
const memStr = trim.split(':')[1].replace('MB', '').trim();
glxMem = parseInt(memStr, 10);
}
}
// If glxinfo reports Intel and we have an Intel integrated GPU, update it
// We check vendor match to ensure we don't accidentally assign Nvidia VRAM to Intel if user is running on dGPU
if (glxVendor === 'intel' && gpus.integrated[0].vendor === 'intel' && glxMem > 0) {
gpus.integrated[0].vram = glxMem;
}
} catch (err) {
// glxinfo missing or failed, ignore
}
}
const primaryDedicated = gpus.dedicated[0] || null;
const primaryIntegrated = gpus.integrated[0] || { name: 'Intel GPU', vram: 0 };
return {
mode: primaryDedicated ? 'dedicated' : 'integrated',
vendor: primaryDedicated ? primaryDedicated.vendor : (gpus.integrated[0] ? gpus.integrated[0].vendor : 'intel'),
integratedName: primaryIntegrated.name,
dedicatedName: primaryDedicated ? primaryDedicated.name : null,
dedicatedVram: primaryDedicated ? primaryDedicated.vram : 0,
integratedVram: primaryIntegrated.vram
};
}
function detectGpuWindows() {
const output = execSync('wmic path win32_VideoController get name', { encoding: 'utf8' });
const lines = output.split('\n').map(line => line.trim()).filter(line => line && line !== 'Name');
let output = '';
let commandUsed = 'cim'; // Track which command succeeded
const POWERSHELL_TIMEOUT = 5000; // 5 second timeout to prevent hanging
let integratedName = null;
let dedicatedName = null;
let hasNvidia = false;
let hasAmd = false;
for (const line of lines) {
const lowerLine = line.toLowerCase();
if (lowerLine.includes('nvidia')) {
hasNvidia = true;
dedicatedName = line;
console.log('Detected NVIDIA GPU:', dedicatedName);
} else if (lowerLine.includes('amd') || lowerLine.includes('radeon')) {
hasAmd = true;
dedicatedName = line;
console.log('Detected AMD GPU:', dedicatedName);
} else if (lowerLine.includes('intel')) {
integratedName = line;
console.log('Detected Intel GPU:', integratedName);
try {
// Use spawnSync with explicit timeout instead of execSync to avoid ghost processes
// Fetch Name and AdapterRAM (VRAM in bytes)
const result = spawnSync('powershell.exe', [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-Command',
'Get-CimInstance Win32_VideoController | Select-Object Name, AdapterRAM | ConvertTo-Csv -NoTypeInformation'
], {
encoding: 'utf8',
timeout: POWERSHELL_TIMEOUT,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true
});
if (result.error) {
throw result.error;
}
}
if (hasNvidia) {
return { mode: 'dedicated', vendor: 'nvidia', integratedName: integratedName || 'Intel GPU', dedicatedName };
} else if (hasAmd) {
return { mode: 'dedicated', vendor: 'amd', integratedName: integratedName || 'Intel GPU', dedicatedName };
} else {
return { mode: 'integrated', vendor: 'intel', integratedName: integratedName || 'Intel GPU', dedicatedName: null };
}
}
function detectGpuMac() {
const output = execSync('system_profiler SPDisplaysDataType', { encoding: 'utf8' });
const lines = output.split('\n');
let integratedName = null;
let dedicatedName = null;
let hasNvidia = false;
let hasAmd = false;
for (const line of lines) {
if (line.includes('Chipset Model:')) {
const gpuName = line.split('Chipset Model:')[1].trim();
const lowerGpu = gpuName.toLowerCase();
if (lowerGpu.includes('nvidia')) {
hasNvidia = true;
dedicatedName = gpuName;
console.log('Detected NVIDIA GPU:', dedicatedName);
} else if (lowerGpu.includes('amd') || lowerGpu.includes('radeon')) {
hasAmd = true;
dedicatedName = gpuName;
console.log('Detected AMD GPU:', dedicatedName);
} else if (lowerGpu.includes('intel') || lowerGpu.includes('iris') || lowerGpu.includes('uhd')) {
integratedName = gpuName;
console.log('Detected Intel GPU:', integratedName);
} else if (!dedicatedName && !integratedName) {
// Fallback for Apple Silicon or other
integratedName = gpuName;
if (result.status === 0 && result.stdout) {
output = result.stdout;
} else {
throw new Error(`PowerShell returned status ${result.status || result.signal}`);
}
} catch (e) {
try {
// Fallback to Get-WmiObject (Older PowerShell)
commandUsed = 'wmi';
const result = spawnSync('powershell.exe', [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-Command',
'Get-WmiObject Win32_VideoController | Select-Object Name, AdapterRAM | ConvertTo-Csv -NoTypeInformation'
], {
encoding: 'utf8',
timeout: POWERSHELL_TIMEOUT,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true
});
if (result.error) {
throw result.error;
}
if (result.status === 0 && result.stdout) {
output = result.stdout;
} else {
throw new Error(`PowerShell WMI returned status ${result.status || result.signal}`);
}
} catch (e2) {
// Fallback to wmic (Deprecated, often missing on newer Windows)
// Note: This fallback likely won't provide VRAM in the same reliable CSV format easily,
// so we stick to just getting the Name to at least allow the app to launch.
try {
commandUsed = 'wmic';
const result = spawnSync('wmic.exe', ['path', 'win32_VideoController', 'get', 'name'], {
encoding: 'utf8',
timeout: POWERSHELL_TIMEOUT,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true
});
if (result.error) {
throw result.error;
}
if (result.status === 0 && result.stdout) {
output = result.stdout;
} else {
throw new Error(`wmic returned status ${result.status || result.signal}`);
}
} catch (err) {
console.warn('All Windows GPU detection methods failed:', err.message);
return { mode: 'unknown', vendor: 'none', integratedName: null, dedicatedName: null };
}
}
}
if (hasNvidia) {
return { mode: 'dedicated', vendor: 'nvidia', integratedName: integratedName || 'Integrated GPU', dedicatedName };
} else if (hasAmd) {
return { mode: 'dedicated', vendor: 'amd', integratedName: integratedName || 'Integrated GPU', dedicatedName };
// Parse lines.
// PowerShell CSV output (Get-CimInstance/Get-WmiObject) usually looks like:
// "Name","AdapterRAM"
// "NVIDIA GeForce RTX 3060","12884901888"
//
// WMIC output is just plain text lines with the name (if we used the wmic command above).
const lines = output.split(/\r?\n/).filter(l => l.trim().length > 0);
let gpus = {
integrated: [],
dedicated: []
};
for (const line of lines) {
// Skip header lines
if (line.toLowerCase().includes('name') && (line.includes('AdapterRAM') || commandUsed === 'wmic')) {
continue;
}
let name = '';
let vramBytes = 0;
if (commandUsed === 'wmic') {
name = line.trim();
} else {
// Parse CSV: "Name","AdapterRAM"
// Simple regex to handle potential quotes.
// This assumes simple CSV structure from ConvertTo-Csv.
const parts = line.split(',');
// Remove surrounding quotes if present
const rawName = parts[0] ? parts[0].replace(/^"|"$/g, '') : '';
const rawRam = parts[1] ? parts[1].replace(/^"|"$/g, '') : '0';
name = rawName.trim();
vramBytes = parseInt(rawRam, 10) || 0;
}
if (!name) continue;
const lowerName = name.toLowerCase();
const vramMb = Math.round(vramBytes / (1024 * 1024));
// Logic for dGPU detection; added isIntelArc check
const isNvidia = lowerName.includes('nvidia');
const isAmd = lowerName.includes('amd') || lowerName.includes('radeon');
const isIntelArc = lowerName.includes('arc') && lowerName.includes('intel');
const gpuInfo = {
name: name,
vendor: isNvidia ? 'nvidia' : (isAmd ? 'amd' : (isIntelArc ? 'intel' : 'unknown')),
vram: vramMb
};
if (isNvidia || isAmd || isIntelArc) {
gpus.dedicated.push(gpuInfo);
} else if (lowerName.includes('intel') || lowerName.includes('iris') || lowerName.includes('uhd')) {
gpus.integrated.push(gpuInfo);
} else {
// Fallback: If unknown vendor but high VRAM (> 512MB), treat as dedicated?
// Or just assume integrated if generic "Microsoft Basic Display Adapter" etc.
// For now, if we can't identify it as dedicated vendor, put in integrated/other.
gpus.integrated.push(gpuInfo);
}
}
const primaryDedicated = gpus.dedicated[0] || null;
const primaryIntegrated = gpus.integrated[0] || { name: 'Intel GPU', vram: 0 };
return {
mode: primaryDedicated ? 'dedicated' : 'integrated',
vendor: primaryDedicated ? primaryDedicated.vendor : 'intel', // Default to intel if only integrated found
integratedName: primaryIntegrated.name,
dedicatedName: primaryDedicated ? primaryDedicated.name : null,
// Add VRAM info if available (mostly for debug or UI)
dedicatedVram: primaryDedicated ? primaryDedicated.vram : 0,
integratedVram: primaryIntegrated.vram
};
}
function detectGpuMac() {
let output = '';
try {
output = execSync('system_profiler SPDisplaysDataType', { encoding: 'utf8' });
} catch (e) {
return { mode: 'integrated', vendor: 'intel', integratedName: 'Unknown', dedicatedName: null };
}
const lines = output.split('\n');
let gpus = {
integrated: [],
dedicated: []
};
let currentGpu = null;
for (const line of lines) {
const trimmed = line.trim();
// New block starts with "Chipset Model:"
if (trimmed.startsWith('Chipset Model:')) {
if (currentGpu) {
// Push previous
categorizeMacGpu(currentGpu, gpus);
}
currentGpu = {
name: trimmed.split(':')[1].trim(),
vendor: 'unknown',
vram: 0
};
} else if (currentGpu) {
if (trimmed.startsWith('VRAM (Total):') || trimmed.startsWith('VRAM (Dynamic, Max):')) {
// Parse VRAM: "1.5 GB" or "1536 MB"
const valParts = trimmed.split(':')[1].trim().split(' ');
let val = parseFloat(valParts[0]);
if (valParts[1] && valParts[1].toUpperCase() === 'GB') {
val = val * 1024;
}
currentGpu.vram = Math.round(val);
} else if (trimmed.startsWith('Vendor:') || trimmed.startsWith('Vendor Name:')) {
// "Vendor: NVIDIA (0x10de)"
const v = trimmed.split(':')[1].toLowerCase();
if (v.includes('nvidia')) currentGpu.vendor = 'nvidia';
else if (v.includes('amd') || v.includes('ati')) currentGpu.vendor = 'amd';
else if (v.includes('intel')) currentGpu.vendor = 'intel';
else if (v.includes('apple')) currentGpu.vendor = 'apple';
}
}
}
// Push last one
if (currentGpu) {
categorizeMacGpu(currentGpu, gpus);
}
// If we have an Apple Silicon GPU (vendor=apple) but VRAM is 0, fetch system memory as it is unified.
gpus.dedicated.forEach(gpu => {
if (gpu.vendor === 'apple' && gpu.vram === 0) {
try {
const memSize = execSync('sysctl -n hw.memsize', { encoding: 'utf8' }).trim();
// memSize is in bytes
const memMb = Math.round(parseInt(memSize, 10) / (1024 * 1024));
if (memMb > 0) gpu.vram = memMb;
} catch (err) {
// ignore
}
}
});
const primaryDedicated = gpus.dedicated[0] || null;
const primaryIntegrated = gpus.integrated[0] || { name: 'Integrated GPU', vram: 0 };
return {
mode: primaryDedicated ? 'dedicated' : 'integrated',
vendor: primaryDedicated ? primaryDedicated.vendor : (gpus.integrated[0] ? gpus.integrated[0].vendor : 'intel'),
integratedName: primaryIntegrated.name,
dedicatedName: primaryDedicated ? primaryDedicated.name : null,
dedicatedVram: primaryDedicated ? primaryDedicated.vram : 0,
integratedVram: primaryIntegrated.vram
};
}
function categorizeMacGpu(gpu, gpus) {
const lowerName = gpu.name.toLowerCase();
// Refine vendor if still unknown
if (gpu.vendor === 'unknown') {
if (lowerName.includes('nvidia')) gpu.vendor = 'nvidia';
else if (lowerName.includes('amd') || lowerName.includes('radeon')) gpu.vendor = 'amd';
else if (lowerName.includes('intel')) gpu.vendor = 'intel';
else if (lowerName.includes('apple') || lowerName.includes('m1') || lowerName.includes('m2') || lowerName.includes('m3')) gpu.vendor = 'apple';
}
const isNvidia = gpu.vendor === 'nvidia';
const isAmd = gpu.vendor === 'amd';
const isApple = gpu.vendor === 'apple';
// Per user request, "project is not meant for Intel Mac (x86)",
// so we treat Apple Silicon as the primary "dedicated-like" GPU for this app's context.
if (isNvidia || isAmd || isApple) {
gpus.dedicated.push(gpu);
} else {
return { mode: 'integrated', vendor: 'intel', integratedName: integratedName || 'Integrated GPU', dedicatedName: null };
// Intel or unknown
gpus.integrated.push(gpu);
}
}
@@ -267,11 +604,108 @@ function setupGpuEnvironment(gpuPreference) {
return envVars;
}
function getSystemType() {
const platform = getOS();
try {
if (platform === 'linux') return getSystemTypeLinux();
if (platform === 'windows') return getSystemTypeWindows();
if (platform === 'darwin') return getSystemTypeMac();
return 'desktop'; // Default to desktop if unknown
} catch (err) {
console.warn('Failed to detect system type, defaulting to desktop:', err.message);
return 'desktop';
}
}
function getSystemTypeLinux() {
try {
// Try reliable DMI check first
if (fs.existsSync('/sys/class/dmi/id/chassis_type')) {
const type = parseInt(fs.readFileSync('/sys/class/dmi/id/chassis_type', 'utf8').trim());
// 8=Portable, 9=Laptop, 10=Notebook, 11=Hand Held, 12=Docking Station, 14=Sub Notebook
if ([8, 9, 10, 11, 12, 14, 31, 32].includes(type)) {
return 'laptop';
}
}
// Fallback to chassis_id for some systems? Usually chassis_type is enough.
return 'desktop';
} catch (e) {
return 'desktop';
}
}
function getSystemTypeWindows() {
const POWERSHELL_TIMEOUT = 5000; // 5 second timeout
try {
// Use spawnSync instead of execSync to avoid ghost processes
const result = spawnSync('powershell.exe', [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-Command',
'Get-CimInstance Win32_SystemEnclosure | Select-Object -ExpandProperty ChassisTypes'
], {
encoding: 'utf8',
timeout: POWERSHELL_TIMEOUT,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true
});
if (result.error || result.status !== 0) {
throw new Error(`PowerShell failed: ${result.error?.message || result.signal}`);
}
const output = (result.stdout || '').trim();
// Output might be a single number or array.
// Clean it up
const types = output.split(/\s+/).map(t => parseInt(t)).filter(n => !isNaN(n));
// Laptop codes: 8, 9, 10, 11, 12, 14, 31, 32
const laptopCodes = [8, 9, 10, 11, 12, 14, 31, 32];
for (const t of types) {
if (laptopCodes.includes(t)) return 'laptop';
}
return 'desktop';
} catch (e) {
// Fallback wmic
try {
const result = spawnSync('wmic.exe', ['path', 'win32_systemenclosure', 'get', 'chassistypes'], {
encoding: 'utf8',
timeout: POWERSHELL_TIMEOUT,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true
});
if (result.status === 0 && result.stdout) {
const output = result.stdout.trim();
if (output.includes('8') || output.includes('9') || output.includes('10') || output.includes('14')) {
return 'laptop';
}
}
} catch (err) {
console.warn('System type detection failed:', err.message);
}
return 'desktop';
}
}
function getSystemTypeMac() {
try {
const model = execSync('sysctl -n hw.model', { encoding: 'utf8' }).trim().toLowerCase();
if (model.includes('book')) return 'laptop';
return 'desktop';
} catch (e) {
return 'desktop';
}
}
module.exports = {
getOS,
getArch,
isWaylandSession,
setupWaylandEnvironment,
detectGpu,
setupGpuEnvironment
setupGpuEnvironment,
getSystemType
};

View File

@@ -0,0 +1,426 @@
const crypto = require('crypto');
const axios = require('axios');
const https = require('https');
const { PassThrough } = require('stream');
const PROXY_URL = process.env.HF2P_PROXY_URL || 'your_proxy_url_here';
const SECRET_KEY = process.env.HF2P_SECRET_KEY || 'your_secret_key_here_for_jwt';
const USE_DIRECT_FALLBACK = process.env.HF2P_USE_FALLBACK !== 'false';
const DIRECT_TIMEOUT = 7000; // 7 seconds timeout
console.log('[ProxyClient] Initialized with proxy URL:', PROXY_URL ? 'YES' : 'NO');
console.log('[ProxyClient] Secret key configured:', SECRET_KEY ? 'YES' : 'NO');
console.log('[ProxyClient] Direct connection fallback:', USE_DIRECT_FALLBACK ? 'ENABLED' : 'DISABLED');
console.log('[ProxyClient] Direct timeout before fallback:', DIRECT_TIMEOUT / 1000, 'seconds');
function generateToken() {
const timestamp = Date.now().toString();
const hash = crypto
.createHmac('sha256', SECRET_KEY)
.update(timestamp)
.digest('hex');
const token = `${timestamp}:${hash}`;
console.log('[ProxyClient] Generated auth token:', token.substring(0, 20) + '...');
return token;
}
// Direct request without proxy
async function directRequest(url, options = {}) {
console.log('[ProxyClient] Attempting direct request (no proxy)');
console.log('[ProxyClient] Direct URL:', url);
const timeoutMs = options.timeout || DIRECT_TIMEOUT;
const controller = new AbortController();
const timeoutId = setTimeout(() => {
console.warn('[ProxyClient] TIMEOUT! Aborting direct request after', timeoutMs, 'ms');
controller.abort();
}, timeoutMs);
try {
const config = {
method: options.method || 'GET',
url: url,
headers: options.headers || {},
timeout: timeoutMs,
responseType: options.responseType,
signal: controller.signal
};
const response = await axios(config);
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
// Proxy request (original function)
async function proxyRequest(url, options = {}) {
console.log('[ProxyClient] Starting proxy request');
console.log('[ProxyClient] Original URL:', url);
console.log('[ProxyClient] Options:', JSON.stringify(options, null, 2));
try {
const token = generateToken();
const urlObj = new URL(url);
const targetUrl = `${urlObj.protocol}//${urlObj.host}`;
console.log('[ProxyClient] Parsed URL components:');
console.log(' - Protocol:', urlObj.protocol);
console.log(' - Host:', urlObj.host);
console.log(' - Pathname:', urlObj.pathname);
console.log(' - Search:', urlObj.search);
console.log(' - Target URL:', targetUrl);
const proxyEndpoint = `${PROXY_URL}/proxy${urlObj.pathname}${urlObj.search}`;
console.log('[ProxyClient] Proxy endpoint:', proxyEndpoint);
const config = {
method: options.method || 'GET',
url: proxyEndpoint,
headers: {
'X-Auth-Token': token,
'X-Target-URL': targetUrl,
...(options.headers || {})
},
timeout: options.timeout || 30000,
responseType: options.responseType
};
console.log('[ProxyClient] Request config:', JSON.stringify({
method: config.method,
url: config.url,
headers: config.headers,
timeout: config.timeout,
responseType: config.responseType
}, null, 2));
const response = await axios(config);
console.log('[ProxyClient] Response received - Status:', response.status);
console.log('[ProxyClient] Response headers:', JSON.stringify(response.headers, null, 2));
return response;
} catch (error) {
console.error('[ProxyClient] Request failed!');
console.error('[ProxyClient] Error type:', error.constructor.name);
console.error('[ProxyClient] Error message:', error.message);
if (error.response) {
console.error('[ProxyClient] Response status:', error.response.status);
console.error('[ProxyClient] Response data:', error.response.data);
console.error('[ProxyClient] Response headers:', error.response.headers);
}
if (error.config) {
console.error('[ProxyClient] Failed request URL:', error.config.url);
console.error('[ProxyClient] Failed request headers:', error.config.headers);
}
throw error;
}
}
// Smart request with automatic fallback
async function smartRequest(url, options = {}) {
if (!USE_DIRECT_FALLBACK) {
console.log('[ProxyClient] Fallback disabled, using proxy directly');
return proxyRequest(url, options);
}
console.log('[ProxyClient] Smart request with fallback enabled');
console.log('[ProxyClient] Direct timeout configured:', DIRECT_TIMEOUT, 'ms');
const directStartTime = Date.now();
try {
console.log('[ProxyClient] [ATTEMPT 1/2] Trying direct connection first...');
const response = await directRequest(url, options);
const directDuration = Date.now() - directStartTime;
console.log('[ProxyClient] [SUCCESS] Direct connection successful in', directDuration, 'ms');
return response;
} catch (directError) {
const directDuration = Date.now() - directStartTime;
console.warn('[ProxyClient] [FAILED] Direct connection failed after', directDuration, 'ms');
console.warn('[ProxyClient] Error message:', directError.message);
console.warn('[ProxyClient] Error code:', directError.code);
// Always fallback to proxy on any error
console.log('[ProxyClient] Attempting proxy fallback for all errors...');
if (true) {
console.log('[ProxyClient] [ATTEMPT 2/2] Falling back to proxy connection...');
try {
const proxyStartTime = Date.now();
const response = await proxyRequest(url, options);
const proxyDuration = Date.now() - proxyStartTime;
console.log('[ProxyClient] [SUCCESS] Proxy connection successful in', proxyDuration, 'ms');
return response;
} catch (proxyError) {
console.error('[ProxyClient] [FAILED] Both direct and proxy connections failed!');
console.error('[ProxyClient] Direct error:', directError.message);
console.error('[ProxyClient] Proxy error:', proxyError.message);
throw proxyError;
}
} else {
console.log('[ProxyClient] [SKIP] Direct error not related to connectivity, not falling back');
throw directError;
}
}
}
// Direct download stream without proxy
function directDownloadStream(url, onData) {
console.log('[ProxyClient] Starting direct download stream (no proxy)');
console.log('[ProxyClient] Direct download URL:', url);
return new Promise((resolve, reject) => {
try {
const urlObj = new URL(url);
const protocol = urlObj.protocol === 'https:' ? https : require('http');
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'GET',
timeout: DIRECT_TIMEOUT
};
const handleResponse = (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
const redirectUrl = response.headers.location;
console.log('[ProxyClient] Direct redirect to:', redirectUrl);
directDownloadStream(redirectUrl, onData).then(resolve).catch(reject);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`Direct HTTP ${response.statusCode}`));
return;
}
if (onData) {
const totalSize = parseInt(response.headers['content-length'], 10);
let downloaded = 0;
const passThrough = new PassThrough();
response.on('data', (chunk) => {
downloaded += chunk.length;
onData(chunk, downloaded, totalSize);
});
response.pipe(passThrough);
resolve(passThrough);
} else {
resolve(response);
}
};
const req = protocol.get(options, handleResponse);
req.on('error', (error) => {
console.error('[ProxyClient] Direct download error:', error.message);
reject(error);
});
req.on('timeout', () => {
console.warn('[ProxyClient] TIMEOUT! Direct download timed out after', DIRECT_TIMEOUT, 'ms');
req.destroy();
const timeoutError = new Error('ETIMEDOUT: Direct connection timeout');
timeoutError.code = 'ETIMEDOUT';
reject(timeoutError);
});
} catch (error) {
reject(error);
}
});
}
function getProxyDownloadStream(url, onData) {
console.log('[ProxyClient] Starting download stream');
console.log('[ProxyClient] Download URL:', url);
return new Promise((resolve, reject) => {
try {
const token = generateToken();
const urlObj = new URL(url);
const targetUrl = `${urlObj.protocol}//${urlObj.host}`;
console.log('[ProxyClient] Download URL parsed:');
console.log(' - Protocol:', urlObj.protocol);
console.log(' - Host:', urlObj.host);
console.log(' - Hostname:', urlObj.hostname);
console.log(' - Port:', urlObj.port);
console.log(' - Pathname:', urlObj.pathname);
console.log(' - Search:', urlObj.search);
console.log(' - Target URL:', targetUrl);
const proxyUrl = new URL(PROXY_URL);
const requestPath = `/proxy${urlObj.pathname}${urlObj.search}`;
console.log('[ProxyClient] Proxy configuration:');
console.log(' - Proxy URL:', PROXY_URL);
console.log(' - Proxy protocol:', proxyUrl.protocol);
console.log(' - Proxy hostname:', proxyUrl.hostname);
console.log(' - Proxy port:', proxyUrl.port);
console.log(' - Request path:', requestPath);
const options = {
hostname: proxyUrl.hostname,
port: proxyUrl.port || (proxyUrl.protocol === 'https:' ? 443 : 80),
path: requestPath,
method: 'GET',
headers: {
'X-Auth-Token': token,
'X-Target-URL': targetUrl
}
};
console.log('[ProxyClient] HTTP request options:', JSON.stringify(options, null, 2));
const protocol = proxyUrl.protocol === 'https:' ? https : require('http');
console.log('[ProxyClient] Using protocol:', proxyUrl.protocol);
const handleResponse = (response) => {
console.log('[ProxyClient] Response received - Status:', response.statusCode);
console.log('[ProxyClient] Response headers:', JSON.stringify(response.headers, null, 2));
if (response.statusCode === 302 || response.statusCode === 301) {
const redirectUrl = response.headers.location;
console.log('[ProxyClient] Redirect detected to:', redirectUrl);
if (redirectUrl.startsWith('http')) {
console.log('[ProxyClient] Following redirect...');
getProxyDownloadStream(redirectUrl, onData).then(resolve).catch(reject);
} else {
console.error('[ProxyClient] Invalid redirect URL:', redirectUrl);
reject(new Error(`Invalid redirect: ${redirectUrl}`));
}
return;
}
if (response.statusCode !== 200) {
console.error('[ProxyClient] Unexpected status code:', response.statusCode);
console.error('[ProxyClient] Response message:', response.statusMessage);
reject(new Error(`HTTP ${response.statusCode}`));
return;
}
if (onData) {
const totalSize = parseInt(response.headers['content-length'], 10);
console.log('[ProxyClient] Download starting - Total size:', totalSize, 'bytes');
let downloaded = 0;
const passThrough = new PassThrough();
response.on('data', (chunk) => {
downloaded += chunk.length;
const progress = ((downloaded / totalSize) * 100).toFixed(2);
onData(chunk, downloaded, totalSize);
});
response.on('end', () => {
console.log('[ProxyClient] Download completed -', downloaded, 'bytes received');
});
response.on('error', (error) => {
console.error('[ProxyClient] Response stream error:', error.message);
});
response.pipe(passThrough);
console.log('[ProxyClient] Stream piped to PassThrough');
resolve(passThrough);
} else {
console.log('[ProxyClient] Returning raw response stream (no progress callback)');
resolve(response);
}
};
const request = protocol.get(options, handleResponse);
request.on('error', (error) => {
console.error('[ProxyClient] HTTP request error!');
console.error('[ProxyClient] Error type:', error.constructor.name);
console.error('[ProxyClient] Error message:', error.message);
console.error('[ProxyClient] Error code:', error.code);
console.error('[ProxyClient] Error stack:', error.stack);
reject(error);
});
console.log('[ProxyClient] HTTP request sent');
} catch (error) {
console.error('[ProxyClient] Exception in getProxyDownloadStream!');
console.error('[ProxyClient] Error type:', error.constructor.name);
console.error('[ProxyClient] Error message:', error.message);
console.error('[ProxyClient] Error stack:', error.stack);
reject(error);
}
});
}
// Smart download stream with automatic fallback
function smartDownloadStream(url, onData) {
if (!USE_DIRECT_FALLBACK) {
console.log('[ProxyClient] Fallback disabled, using proxy stream directly');
return getProxyDownloadStream(url, onData);
}
console.log('[ProxyClient] Smart download stream with fallback enabled');
console.log('[ProxyClient] Direct timeout configured:', DIRECT_TIMEOUT, 'ms');
return new Promise(async (resolve, reject) => {
const directStartTime = Date.now();
try {
console.log('[ProxyClient] [DOWNLOAD 1/2] Trying direct download first...');
const stream = await directDownloadStream(url, onData);
const directDuration = Date.now() - directStartTime;
console.log('[ProxyClient] [SUCCESS] Direct download stream established in', directDuration, 'ms');
resolve(stream);
} catch (directError) {
const directDuration = Date.now() - directStartTime;
console.warn('[ProxyClient] [FAILED] Direct download failed after', directDuration, 'ms');
console.warn('[ProxyClient] Error message:', directError.message);
console.warn('[ProxyClient] Error code:', directError.code);
// Always fallback to proxy on any error
console.log('[ProxyClient] Attempting proxy fallback for all download errors...');
if (true) {
console.log('[ProxyClient] [DOWNLOAD 2/2] Falling back to proxy download...');
try {
const proxyStartTime = Date.now();
const stream = await getProxyDownloadStream(url, onData);
const proxyDuration = Date.now() - proxyStartTime;
console.log('[ProxyClient] [SUCCESS] Proxy download stream established in', proxyDuration, 'ms');
resolve(stream);
} catch (proxyError) {
console.error('[ProxyClient] [FAILED] Both direct and proxy downloads failed!');
console.error('[ProxyClient] Direct error:', directError.message);
console.error('[ProxyClient] Proxy error:', proxyError.message);
reject(proxyError);
}
} else {
console.log('[ProxyClient] [SKIP] Direct error not related to connectivity, not falling back');
reject(directError);
}
}
});
}
module.exports = {
// Recommended: Smart functions with automatic fallback
smartRequest,
smartDownloadStream,
// Legacy: Direct proxy functions (for manual control)
proxyRequest,
getProxyDownloadStream,
// Direct functions (no proxy)
directRequest,
directDownloadStream,
// Utilities
generateToken
};