mirror of
https://git.sanhost.net/sanasol/hytale-f2p
synced 2026-02-26 10:31:47 -03:00
update 2.0.2
This commit is contained in:
@@ -2,6 +2,36 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
|
||||
// Default auth domain - can be overridden by env var or config
|
||||
const DEFAULT_AUTH_DOMAIN = 'sanasol.ws';
|
||||
|
||||
// Get auth domain from env, config, or default
|
||||
function getAuthDomain() {
|
||||
// First check environment variable
|
||||
if (process.env.HYTALE_AUTH_DOMAIN) {
|
||||
return process.env.HYTALE_AUTH_DOMAIN;
|
||||
}
|
||||
// Then check config file
|
||||
const config = loadConfig();
|
||||
if (config.authDomain) {
|
||||
return config.authDomain;
|
||||
}
|
||||
// Fall back to default
|
||||
return DEFAULT_AUTH_DOMAIN;
|
||||
}
|
||||
|
||||
// Get full auth server URL
|
||||
function getAuthServerUrl() {
|
||||
const domain = getAuthDomain();
|
||||
return `https://sessions.${domain}`;
|
||||
}
|
||||
|
||||
// Save auth domain to config
|
||||
function saveAuthDomain(domain) {
|
||||
saveConfig({ authDomain: domain || DEFAULT_AUTH_DOMAIN });
|
||||
}
|
||||
|
||||
function getAppDir() {
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'win32') {
|
||||
@@ -94,6 +124,15 @@ function loadInstallPath() {
|
||||
return config.installPath || '';
|
||||
}
|
||||
|
||||
function saveDiscordRPC(enabled) {
|
||||
saveConfig({ discordRPC: !!enabled });
|
||||
}
|
||||
|
||||
function loadDiscordRPC() {
|
||||
const config = loadConfig();
|
||||
return config.discordRPC !== undefined ? config.discordRPC : true;
|
||||
}
|
||||
|
||||
function saveModsToConfig(mods) {
|
||||
try {
|
||||
let config = loadConfig();
|
||||
@@ -143,6 +182,70 @@ 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);
|
||||
}
|
||||
|
||||
function saveChatColor(color) {
|
||||
const config = loadConfig();
|
||||
config.chatColor = color;
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
function loadChatColor() {
|
||||
const config = loadConfig();
|
||||
return config.chatColor || '#3498db';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
@@ -150,14 +253,29 @@ module.exports = {
|
||||
loadUsername,
|
||||
saveChatUsername,
|
||||
loadChatUsername,
|
||||
saveChatColor,
|
||||
loadChatColor,
|
||||
getUuidForUser,
|
||||
saveJavaPath,
|
||||
loadJavaPath,
|
||||
saveInstallPath,
|
||||
loadInstallPath,
|
||||
saveDiscordRPC,
|
||||
loadDiscordRPC,
|
||||
saveModsToConfig,
|
||||
loadModsFromConfig,
|
||||
isFirstLaunch,
|
||||
markAsLaunched,
|
||||
CONFIG_FILE
|
||||
CONFIG_FILE,
|
||||
// Auth server exports
|
||||
getAuthServerUrl,
|
||||
getAuthDomain,
|
||||
saveAuthDomain,
|
||||
// UUID Management exports
|
||||
getCurrentUuid,
|
||||
getAllUuidMappings,
|
||||
setUuidForUser,
|
||||
generateNewUuid,
|
||||
deleteUuidForUser,
|
||||
resetCurrentUserUuid
|
||||
};
|
||||
|
||||
@@ -7,16 +7,27 @@ const {
|
||||
loadUsername,
|
||||
saveChatUsername,
|
||||
loadChatUsername,
|
||||
saveChatColor,
|
||||
loadChatColor,
|
||||
saveJavaPath,
|
||||
loadJavaPath,
|
||||
saveInstallPath,
|
||||
loadInstallPath,
|
||||
saveDiscordRPC,
|
||||
loadDiscordRPC,
|
||||
saveModsToConfig,
|
||||
loadModsFromConfig,
|
||||
getUuidForUser,
|
||||
isFirstLaunch,
|
||||
markAsLaunched,
|
||||
CONFIG_FILE
|
||||
CONFIG_FILE,
|
||||
// UUID Management
|
||||
getCurrentUuid,
|
||||
getAllUuidMappings,
|
||||
setUuidForUser,
|
||||
generateNewUuid,
|
||||
deleteUuidForUser,
|
||||
resetCurrentUserUuid
|
||||
} = require('./core/config');
|
||||
|
||||
const { getResolvedAppDir, getModsPath } = require('./core/paths');
|
||||
@@ -37,8 +48,6 @@ const {
|
||||
|
||||
const { getJavaDetection } = require('./managers/javaManager');
|
||||
|
||||
const { checkAndInstallMultiClient } = require('./managers/multiClientManager');
|
||||
|
||||
const {
|
||||
downloadAndReplaceHomePageUI,
|
||||
findHomePageUIPath,
|
||||
@@ -85,6 +94,8 @@ module.exports = {
|
||||
loadUsername,
|
||||
saveChatUsername,
|
||||
loadChatUsername,
|
||||
saveChatColor,
|
||||
loadChatColor,
|
||||
getUuidForUser,
|
||||
|
||||
// Java configuration functions
|
||||
@@ -96,6 +107,10 @@ module.exports = {
|
||||
saveInstallPath,
|
||||
loadInstallPath,
|
||||
|
||||
// Discord RPC functions
|
||||
saveDiscordRPC,
|
||||
loadDiscordRPC,
|
||||
|
||||
// Version functions
|
||||
getInstalledClientVersion,
|
||||
getLatestClientVersion,
|
||||
@@ -106,8 +121,13 @@ module.exports = {
|
||||
// Player ID functions
|
||||
getOrCreatePlayerId,
|
||||
|
||||
// Multi-client functions
|
||||
checkAndInstallMultiClient,
|
||||
// UUID Management functions
|
||||
getCurrentUuid,
|
||||
getAllUuidMappings,
|
||||
setUuidForUser,
|
||||
generateNewUuid,
|
||||
deleteUuidForUser,
|
||||
resetCurrentUserUuid,
|
||||
|
||||
// Mod management functions
|
||||
getModsPath,
|
||||
|
||||
@@ -1,17 +1,105 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const { spawn } = require('child_process');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { getResolvedAppDir, findClientPath } = require('../core/paths');
|
||||
const { setupWaylandEnvironment } = require('../utils/platformUtils');
|
||||
const { saveUsername, saveInstallPath, loadJavaPath, getUuidForUser } = require('../core/config');
|
||||
const { saveUsername, saveInstallPath, loadJavaPath, getUuidForUser, getAuthServerUrl, getAuthDomain } = require('../core/config');
|
||||
const { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA_EXECUTABLE } = require('./javaManager');
|
||||
const { getInstalledClientVersion, getLatestClientVersion } = require('../services/versionManager');
|
||||
const { updateGameFiles } = require('./gameManager');
|
||||
|
||||
// Client patcher for custom auth server (sanasol.ws)
|
||||
let clientPatcher = null;
|
||||
try {
|
||||
clientPatcher = require('../utils/clientPatcher');
|
||||
} catch (err) {
|
||||
console.log('[Launcher] Client patcher not available:', err.message);
|
||||
}
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Fetch tokens from the auth server (properly signed with server's Ed25519 key)
|
||||
async function fetchAuthTokens(uuid, name) {
|
||||
const authServerUrl = getAuthServerUrl();
|
||||
try {
|
||||
console.log(`Fetching auth tokens from ${authServerUrl}/game-session/child`);
|
||||
|
||||
const response = await fetch(`${authServerUrl}/game-session/child`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
uuid: uuid,
|
||||
name: name,
|
||||
scopes: ['hytale:server', 'hytale:client']
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Auth server returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Auth tokens received from server');
|
||||
|
||||
return {
|
||||
identityToken: data.IdentityToken || data.identityToken,
|
||||
sessionToken: data.SessionToken || data.sessionToken
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch auth tokens:', error.message);
|
||||
// Fallback to local generation if server unavailable
|
||||
return generateLocalTokens(uuid, name);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Generate tokens locally (won't pass signature validation but allows offline testing)
|
||||
function generateLocalTokens(uuid, name) {
|
||||
console.log('Using locally generated tokens (fallback mode)');
|
||||
const authServerUrl = getAuthServerUrl();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = now + 36000;
|
||||
|
||||
const header = Buffer.from(JSON.stringify({
|
||||
alg: 'EdDSA',
|
||||
kid: '2025-10-01',
|
||||
typ: 'JWT'
|
||||
})).toString('base64url');
|
||||
|
||||
const identityPayload = Buffer.from(JSON.stringify({
|
||||
sub: uuid,
|
||||
name: name,
|
||||
username: name,
|
||||
entitlements: ['game.base'],
|
||||
scope: 'hytale:server hytale:client',
|
||||
iat: now,
|
||||
exp: exp,
|
||||
iss: authServerUrl,
|
||||
jti: uuidv4()
|
||||
})).toString('base64url');
|
||||
|
||||
const sessionPayload = Buffer.from(JSON.stringify({
|
||||
sub: uuid,
|
||||
scope: 'hytale:server',
|
||||
iat: now,
|
||||
exp: exp,
|
||||
iss: authServerUrl,
|
||||
jti: uuidv4()
|
||||
})).toString('base64url');
|
||||
|
||||
const signature = crypto.randomBytes(64).toString('base64url');
|
||||
|
||||
return {
|
||||
identityToken: `${header}.${identityPayload}.${signature}`,
|
||||
sessionToken: `${header}.${sessionPayload}.${signature}`
|
||||
};
|
||||
}
|
||||
|
||||
async function launchGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride) {
|
||||
const customAppDir = getResolvedAppDir(installPathOverride);
|
||||
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
|
||||
@@ -53,6 +141,51 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
|
||||
}
|
||||
}
|
||||
|
||||
const uuid = getUuidForUser(playerName);
|
||||
|
||||
// Fetch tokens from auth server
|
||||
if (progressCallback) {
|
||||
progressCallback('Fetching authentication tokens...', null, null, null, null);
|
||||
}
|
||||
const { identityToken, sessionToken } = await fetchAuthTokens(uuid, playerName);
|
||||
|
||||
// Patch client and server binaries to use custom auth server (BEFORE signing on macOS)
|
||||
const authDomain = getAuthDomain();
|
||||
if (clientPatcher) {
|
||||
try {
|
||||
if (progressCallback) {
|
||||
progressCallback('Patching game for custom server...', null, null, null, null);
|
||||
}
|
||||
console.log(`Patching game binaries for ${authDomain}...`);
|
||||
|
||||
const patchResult = await clientPatcher.ensureClientPatched(gameLatest, (msg, percent) => {
|
||||
console.log(`[Patcher] ${msg}`);
|
||||
if (progressCallback && msg) {
|
||||
progressCallback(msg, percent, null, null, null);
|
||||
}
|
||||
});
|
||||
|
||||
if (patchResult.success) {
|
||||
if (patchResult.alreadyPatched) {
|
||||
console.log(`Game already patched for ${authDomain}`);
|
||||
} else {
|
||||
console.log(`Game patched successfully (${patchResult.patchCount} total occurrences)`);
|
||||
if (patchResult.client) {
|
||||
console.log(` Client: ${patchResult.client.patchCount || 0} occurrences`);
|
||||
}
|
||||
if (patchResult.server) {
|
||||
console.log(` Server: ${patchResult.server.patchCount || 0} occurrences`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('Game patching failed:', patchResult.error);
|
||||
}
|
||||
} catch (patchError) {
|
||||
console.warn('Game patching failed (game may not connect to custom server):', patchError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// macOS: Sign binaries AFTER patching so the patched binaries have valid signatures
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const appBundle = path.join(gameLatest, 'Client', 'Hytale.app');
|
||||
@@ -66,10 +199,10 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
|
||||
|
||||
if (fs.existsSync(appBundle)) {
|
||||
await signPath(appBundle, true);
|
||||
console.log('Signed macOS app bundle');
|
||||
console.log('Signed macOS app bundle (after patching)');
|
||||
} else {
|
||||
await signPath(path.dirname(clientPath), true);
|
||||
console.log('Signed macOS client binary');
|
||||
console.log('Signed macOS client binary (after patching)');
|
||||
}
|
||||
|
||||
if (javaBin && fs.existsSync(javaBin)) {
|
||||
@@ -85,7 +218,7 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
|
||||
if (fs.existsSync(serverDir)) {
|
||||
await execAsync(`xattr -cr "${serverDir}"`).catch(() => {});
|
||||
await execAsync(`find "${serverDir}" -type f -perm +111 -exec codesign --force --sign - {} \\;`).catch(() => {});
|
||||
console.log('Signed server binaries');
|
||||
console.log('Signed server binaries (after patching)');
|
||||
}
|
||||
|
||||
if (javaBin && fs.existsSync(javaBin)) {
|
||||
@@ -113,13 +246,14 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
|
||||
}
|
||||
}
|
||||
|
||||
const uuid = getUuidForUser(playerName);
|
||||
const args = [
|
||||
'--app-dir', gameLatest,
|
||||
'--java-exec', javaBin,
|
||||
'--auth-mode', 'offline',
|
||||
'--auth-mode', 'authenticated',
|
||||
'--uuid', uuid,
|
||||
'--name', playerName,
|
||||
'--identity-token', identityToken,
|
||||
'--session-token', sessionToken,
|
||||
'--user-dir', userDataDir
|
||||
];
|
||||
|
||||
@@ -269,4 +403,4 @@ async function launchGameWithVersionCheck(playerName = 'Player', progressCallbac
|
||||
module.exports = {
|
||||
launchGame,
|
||||
launchGameWithVersionCheck
|
||||
};
|
||||
};
|
||||
@@ -6,7 +6,6 @@ const { getOS, getArch } = require('../utils/platformUtils');
|
||||
const { downloadFile } = require('../utils/fileManager');
|
||||
const { getLatestClientVersion, getInstalledClientVersion } = require('../services/versionManager');
|
||||
const { installButler } = require('./butlerManager');
|
||||
const { checkAndInstallMultiClient } = require('./multiClientManager');
|
||||
const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFileManager');
|
||||
const { saveUsername, saveInstallPath, loadJavaPath, CONFIG_FILE, loadConfig } = require('../core/config');
|
||||
const { resolveJavaPath, detectSystemJava, downloadJRE, getJavaExec, getBundledJavaPath } = require('./javaManager');
|
||||
@@ -165,9 +164,6 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
|
||||
|
||||
fs.renameSync(tempUpdateDir, gameDir);
|
||||
|
||||
const multiResult = await checkAndInstallMultiClient(gameDir, progressCallback);
|
||||
console.log('Multiplayer-client check result after update:', multiResult);
|
||||
|
||||
const homeUIResult = await downloadAndReplaceHomePageUI(gameDir, progressCallback);
|
||||
console.log('HomePage.ui update result after update:', homeUIResult);
|
||||
|
||||
@@ -318,9 +314,6 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver
|
||||
const pwrFile = await downloadPWR('release', latestVersion, progressCallback, customCacheDir);
|
||||
await applyPWR(pwrFile, progressCallback, customGameDir, customToolsDir);
|
||||
|
||||
const multiResult = await checkAndInstallMultiClient(customGameDir, progressCallback);
|
||||
console.log('Multiplayer check result:', multiResult);
|
||||
|
||||
const homeUIResult = await downloadAndReplaceHomePageUI(customGameDir, progressCallback);
|
||||
console.log('HomePage.ui update result after installation:', homeUIResult);
|
||||
|
||||
@@ -334,8 +327,7 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver
|
||||
|
||||
return {
|
||||
success: true,
|
||||
installed: true,
|
||||
multiClient: multiResult
|
||||
installed: true
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { findClientPath } = require('../core/paths');
|
||||
const { downloadFile } = require('../utils/fileManager');
|
||||
const { getLatestClientVersion, getMultiClientVersion } = require('../services/versionManager');
|
||||
|
||||
async function downloadMultiClient(gameDir, progressCallback) {
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
console.log('Multiplayer-client is only available for Windows');
|
||||
return { success: false, reason: 'Platform not supported' };
|
||||
}
|
||||
|
||||
const clientPath = findClientPath(gameDir);
|
||||
if (!clientPath) {
|
||||
throw new Error('Game client not found. Install game first.');
|
||||
}
|
||||
|
||||
console.log('Downloading Multiplayer from server...');
|
||||
if (progressCallback) {
|
||||
progressCallback('Downloading Multiplayer...', null, null, null, null);
|
||||
}
|
||||
|
||||
const clientUrl = 'http://3.10.208.30:3002/client';
|
||||
const tempClientPath = path.join(path.dirname(clientPath), 'HytaleClient_temp.exe');
|
||||
|
||||
await downloadFile(clientUrl, tempClientPath, progressCallback);
|
||||
|
||||
const backupPath = path.join(path.dirname(clientPath), 'HytaleClient_original.exe');
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
fs.copyFileSync(clientPath, backupPath);
|
||||
console.log('Original client backed up');
|
||||
}
|
||||
|
||||
fs.renameSync(tempClientPath, clientPath);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Multiplayer installed', 100, null, null, null);
|
||||
}
|
||||
console.log('Multiplayer installed successfully');
|
||||
|
||||
return { success: true, installed: true };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error installing Multiplayer:', error);
|
||||
throw new Error(`Failed to install Multiplayer: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndInstallMultiClient(gameDir, progressCallback) {
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
console.log('Multiplayer check skipped (Windows only)');
|
||||
return { success: true, skipped: true, reason: 'Windows only' };
|
||||
}
|
||||
|
||||
console.log('Checking for Multiplayer availability...');
|
||||
|
||||
const [clientVersion, multiVersion] = await Promise.all([
|
||||
getLatestClientVersion(),
|
||||
getMultiClientVersion()
|
||||
]);
|
||||
|
||||
if (!multiVersion) {
|
||||
console.log('Multiplayer not available');
|
||||
return { success: true, skipped: true, reason: 'Multiplayer not available' };
|
||||
}
|
||||
|
||||
if (clientVersion === multiVersion) {
|
||||
console.log(`Versions match (${clientVersion}), installing Multiplayer...`);
|
||||
return await downloadMultiClient(gameDir, progressCallback);
|
||||
} else {
|
||||
console.log(`Version mismatch: client=${clientVersion}, multi=${multiVersion}`);
|
||||
return { success: true, skipped: true, reason: 'Version mismatch' };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Multiplayer:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
downloadMultiClient,
|
||||
checkAndInstallMultiClient
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ async function downloadAndReplaceHomePageUI(gameDir, progressCallback) {
|
||||
progressCallback('Downloading HomePage.ui...', null, null, null, null);
|
||||
}
|
||||
|
||||
const homeUIUrl = 'http://3.10.208.30:3002/api/HomeUI';
|
||||
const homeUIUrl = 'https://files.hytalef2p.com/api/HomeUI';
|
||||
const tempHomePath = path.join(path.dirname(gameDir), 'HomePage_temp.ui');
|
||||
|
||||
await downloadFile(homeUIUrl, tempHomePath);
|
||||
@@ -63,7 +63,7 @@ async function downloadAndReplaceLogo(gameDir, progressCallback) {
|
||||
progressCallback('Downloading Logo@2x.png...', null, null, null, null);
|
||||
}
|
||||
|
||||
const logoUrl = 'http://3.10.208.30:3002/api/Logo';
|
||||
const logoUrl = 'https://files.hytalef2p.com/api/Logo';
|
||||
const tempLogoPath = path.join(path.dirname(gameDir), 'Logo@2x_temp.png');
|
||||
|
||||
await downloadFile(logoUrl, tempLogoPath);
|
||||
|
||||
@@ -3,7 +3,7 @@ const axios = require('axios');
|
||||
async function getLatestClientVersion() {
|
||||
try {
|
||||
console.log('Fetching latest client version from API...');
|
||||
const response = await axios.get('http://3.10.208.30:3002/api/version_client', {
|
||||
const response = await axios.get('https://files.hytalef2p.com/api/version_client', {
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'User-Agent': 'Hytale-F2P-Launcher'
|
||||
@@ -28,7 +28,7 @@ async function getLatestClientVersion() {
|
||||
async function getInstalledClientVersion() {
|
||||
try {
|
||||
console.log('Fetching installed client version from API...');
|
||||
const response = await axios.get('http://3.10.208.30:3002/api/clientCheck', {
|
||||
const response = await axios.get('https://files.hytalef2p.com/api/clientCheck', {
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'User-Agent': 'Hytale-F2P-Launcher'
|
||||
@@ -50,33 +50,7 @@ async function getInstalledClientVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getMultiClientVersion() {
|
||||
try {
|
||||
console.log('Fetching Multiplayer version from API...');
|
||||
const response = await axios.get('http://3.10.208.30:3002/api/multi', {
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'User-Agent': 'Hytale-F2P-Launcher'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data && response.data.multi_version) {
|
||||
const version = response.data.multi_version;
|
||||
console.log(`Multiplayer version: ${version}`);
|
||||
return version;
|
||||
} else {
|
||||
console.log('Warning: Invalid multi API response');
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching Multiplayer version:', error.message);
|
||||
console.log('Multiplayer not available');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getLatestClientVersion,
|
||||
getInstalledClientVersion,
|
||||
getMultiClientVersion
|
||||
getInstalledClientVersion
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const UPDATE_CHECK_URL = 'http://3.10.208.30:3002/api/version_launcher';
|
||||
const CURRENT_VERSION = '2.0.1';
|
||||
const UPDATE_CHECK_URL = 'https://files.hytalef2p.com/api/version_launcher';
|
||||
const CURRENT_VERSION = '2.0.2';
|
||||
const GITHUB_DOWNLOAD_URL = 'https://github.com/amiayweb/Hytale-F2P/';
|
||||
|
||||
class UpdateManager {
|
||||
|
||||
468
backend/utils/clientPatcher.js
Normal file
468
backend/utils/clientPatcher.js
Normal file
@@ -0,0 +1,468 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const AdmZip = require('adm-zip');
|
||||
|
||||
// Domain configuration
|
||||
const ORIGINAL_DOMAIN = 'hytale.com';
|
||||
|
||||
function getTargetDomain() {
|
||||
if (process.env.HYTALE_AUTH_DOMAIN) {
|
||||
return process.env.HYTALE_AUTH_DOMAIN;
|
||||
}
|
||||
try {
|
||||
const { getAuthDomain } = require('../core/config');
|
||||
return getAuthDomain();
|
||||
} catch (e) {
|
||||
return 'sanasol.ws';
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_NEW_DOMAIN = 'sanasol.ws';
|
||||
|
||||
/**
|
||||
* Patches HytaleClient and HytaleServer binaries to replace hytale.com with custom domain
|
||||
* This allows the game to connect to a custom authentication server
|
||||
*/
|
||||
class ClientPatcher {
|
||||
constructor() {
|
||||
this.patchedFlag = '.patched_custom';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target domain for patching
|
||||
*/
|
||||
getNewDomain() {
|
||||
const domain = getTargetDomain();
|
||||
if (domain.length !== ORIGINAL_DOMAIN.length) {
|
||||
console.warn(`Warning: Domain "${domain}" length (${domain.length}) doesn't match original "${ORIGINAL_DOMAIN}" (${ORIGINAL_DOMAIN.length})`);
|
||||
console.warn(`Using default domain: ${DEFAULT_NEW_DOMAIN}`);
|
||||
return DEFAULT_NEW_DOMAIN;
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to UTF-16LE bytes (how .NET stores strings)
|
||||
*/
|
||||
stringToUtf16LE(str) {
|
||||
const buf = Buffer.alloc(str.length * 2);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
buf.writeUInt16LE(str.charCodeAt(i), i * 2);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to UTF-8 bytes (how Java stores strings)
|
||||
*/
|
||||
stringToUtf8(str) {
|
||||
return Buffer.from(str, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all occurrences of a pattern in a buffer
|
||||
*/
|
||||
findAllOccurrences(buffer, pattern) {
|
||||
const positions = [];
|
||||
let pos = 0;
|
||||
while (pos < buffer.length) {
|
||||
const index = buffer.indexOf(pattern, pos);
|
||||
if (index === -1) break;
|
||||
positions.push(index);
|
||||
pos = index + 1;
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTF-8 domain replacement for Java JAR files.
|
||||
* Java stores strings in UTF-8 format in the constant pool.
|
||||
*/
|
||||
findAndReplaceDomainUtf8(data, oldDomain, newDomain) {
|
||||
let count = 0;
|
||||
const result = Buffer.from(data);
|
||||
|
||||
const oldUtf8 = this.stringToUtf8(oldDomain);
|
||||
const newUtf8 = this.stringToUtf8(newDomain);
|
||||
|
||||
const positions = this.findAllOccurrences(result, oldUtf8);
|
||||
|
||||
for (const pos of positions) {
|
||||
newUtf8.copy(result, pos);
|
||||
count++;
|
||||
console.log(` Patched UTF-8 occurrence at offset 0x${pos.toString(16)}`);
|
||||
}
|
||||
|
||||
return { buffer: result, count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart domain replacement that handles both null-terminated and non-null-terminated strings.
|
||||
* .NET AOT stores some strings in various formats:
|
||||
* - Standard UTF-16LE (each char is 2 bytes with \x00 high byte)
|
||||
* - Length-prefixed where last char may have metadata byte instead of \x00
|
||||
*/
|
||||
findAndReplaceDomainSmart(data, oldDomain, newDomain) {
|
||||
let count = 0;
|
||||
const result = Buffer.from(data);
|
||||
|
||||
const oldUtf16NoLast = this.stringToUtf16LE(oldDomain.slice(0, -1));
|
||||
const newUtf16NoLast = this.stringToUtf16LE(newDomain.slice(0, -1));
|
||||
const oldLastChar = this.stringToUtf16LE(oldDomain.slice(-1));
|
||||
const newLastChar = this.stringToUtf16LE(newDomain.slice(-1));
|
||||
|
||||
const oldLastCharByte = oldDomain.charCodeAt(oldDomain.length - 1);
|
||||
const newLastCharByte = newDomain.charCodeAt(newDomain.length - 1);
|
||||
|
||||
const positions = this.findAllOccurrences(result, oldUtf16NoLast);
|
||||
|
||||
for (const pos of positions) {
|
||||
const lastCharPos = pos + oldUtf16NoLast.length;
|
||||
if (lastCharPos + 1 > result.length) continue;
|
||||
|
||||
const lastCharFirstByte = result[lastCharPos];
|
||||
|
||||
if (lastCharFirstByte === oldLastCharByte) {
|
||||
newUtf16NoLast.copy(result, pos);
|
||||
|
||||
result[lastCharPos] = newLastCharByte;
|
||||
|
||||
if (lastCharPos + 1 < result.length) {
|
||||
const secondByte = result[lastCharPos + 1];
|
||||
if (secondByte === 0x00) {
|
||||
console.log(` Patched UTF-16LE occurrence at offset 0x${pos.toString(16)}`);
|
||||
} else {
|
||||
console.log(` Patched length-prefixed occurrence at offset 0x${pos.toString(16)} (metadata: 0x${secondByte.toString(16)})`);
|
||||
}
|
||||
}
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return { buffer: result, count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client binary has already been patched
|
||||
*/
|
||||
isPatchedAlready(clientPath) {
|
||||
const newDomain = this.getNewDomain();
|
||||
const patchFlagFile = clientPath + this.patchedFlag;
|
||||
if (fs.existsSync(patchFlagFile)) {
|
||||
try {
|
||||
const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8'));
|
||||
if (flagData.targetDomain === newDomain) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the client as patched
|
||||
*/
|
||||
markAsPatched(clientPath) {
|
||||
const newDomain = this.getNewDomain();
|
||||
const patchFlagFile = clientPath + this.patchedFlag;
|
||||
const flagData = {
|
||||
patchedAt: new Date().toISOString(),
|
||||
originalDomain: ORIGINAL_DOMAIN,
|
||||
targetDomain: newDomain,
|
||||
patcherVersion: '1.0.0'
|
||||
};
|
||||
fs.writeFileSync(patchFlagFile, JSON.stringify(flagData, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup of the original client binary
|
||||
*/
|
||||
backupClient(clientPath) {
|
||||
const backupPath = clientPath + '.original';
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
console.log(` Creating backup at ${path.basename(backupPath)}`);
|
||||
fs.copyFileSync(clientPath, backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
console.log(' Backup already exists');
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the original client binary from backup
|
||||
*/
|
||||
restoreClient(clientPath) {
|
||||
const backupPath = clientPath + '.original';
|
||||
if (fs.existsSync(backupPath)) {
|
||||
fs.copyFileSync(backupPath, clientPath);
|
||||
const patchFlagFile = clientPath + this.patchedFlag;
|
||||
if (fs.existsSync(patchFlagFile)) {
|
||||
fs.unlinkSync(patchFlagFile);
|
||||
}
|
||||
console.log('Client restored from backup');
|
||||
return true;
|
||||
}
|
||||
console.log('No backup found to restore');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the client binary to use the custom domain
|
||||
* @param {string} clientPath - Path to the HytaleClient binary
|
||||
* @param {function} progressCallback - Optional callback for progress updates
|
||||
* @returns {object} Result object with success status and details
|
||||
*/
|
||||
async patchClient(clientPath, progressCallback) {
|
||||
const newDomain = this.getNewDomain();
|
||||
console.log('=== Client Patcher ===');
|
||||
console.log(`Target: ${clientPath}`);
|
||||
console.log(`Replacing: ${ORIGINAL_DOMAIN} -> ${newDomain}`);
|
||||
|
||||
if (!fs.existsSync(clientPath)) {
|
||||
const error = `Client binary not found: ${clientPath}`;
|
||||
console.error(error);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
if (this.isPatchedAlready(clientPath)) {
|
||||
console.log(`Client already patched for ${newDomain}, skipping`);
|
||||
if (progressCallback) {
|
||||
progressCallback('Client already patched', 100);
|
||||
}
|
||||
return { success: true, alreadyPatched: true, patchCount: 0 };
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Preparing to patch client...', 10);
|
||||
}
|
||||
|
||||
console.log('Creating backup...');
|
||||
this.backupClient(clientPath);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Reading client binary...', 20);
|
||||
}
|
||||
|
||||
console.log('Reading client binary...');
|
||||
const data = fs.readFileSync(clientPath);
|
||||
console.log(`Binary size: ${(data.length / 1024 / 1024).toFixed(2)} MB`);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Patching domain references...', 50);
|
||||
}
|
||||
|
||||
console.log('Patching domain references...');
|
||||
const { buffer: patchedData, count } = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, newDomain);
|
||||
|
||||
if (count === 0) {
|
||||
console.log('No occurrences of hytale.com found - binary may already be modified or has different format');
|
||||
return { success: true, patchCount: 0, warning: 'No domain occurrences found' };
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Writing patched binary...', 80);
|
||||
}
|
||||
|
||||
console.log('Writing patched binary...');
|
||||
fs.writeFileSync(clientPath, patchedData);
|
||||
|
||||
this.markAsPatched(clientPath);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Patching complete', 100);
|
||||
}
|
||||
|
||||
console.log(`Successfully patched ${count} occurrences`);
|
||||
console.log('=== Patching Complete ===');
|
||||
|
||||
return { success: true, patchCount: count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the server JAR to use the custom domain
|
||||
* JAR files are ZIP archives, so we need to extract, patch class files, and repackage
|
||||
* @param {string} serverPath - Path to the HytaleServer.jar
|
||||
* @param {function} progressCallback - Optional callback for progress updates
|
||||
* @returns {object} Result object with success status and details
|
||||
*/
|
||||
async patchServer(serverPath, progressCallback) {
|
||||
const newDomain = this.getNewDomain();
|
||||
console.log('=== Server Patcher ===');
|
||||
console.log(`Target: ${serverPath}`);
|
||||
console.log(`Replacing: ${ORIGINAL_DOMAIN} -> ${newDomain}`);
|
||||
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
const error = `Server JAR not found: ${serverPath}`;
|
||||
console.error(error);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
if (this.isPatchedAlready(serverPath)) {
|
||||
console.log(`Server already patched for ${newDomain}, skipping`);
|
||||
if (progressCallback) {
|
||||
progressCallback('Server already patched', 100);
|
||||
}
|
||||
return { success: true, alreadyPatched: true, patchCount: 0 };
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Preparing to patch server...', 10);
|
||||
}
|
||||
|
||||
console.log('Creating backup...');
|
||||
this.backupClient(serverPath);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Extracting server JAR...', 20);
|
||||
}
|
||||
|
||||
console.log('Opening server JAR...');
|
||||
const zip = new AdmZip(serverPath);
|
||||
const entries = zip.getEntries();
|
||||
console.log(`JAR contains ${entries.length} entries`);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Patching class files...', 40);
|
||||
}
|
||||
|
||||
let totalCount = 0;
|
||||
const oldUtf8 = this.stringToUtf8(ORIGINAL_DOMAIN);
|
||||
const newUtf8 = this.stringToUtf8(newDomain);
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = entry.entryName;
|
||||
if (name.endsWith('.class') || name.endsWith('.properties') ||
|
||||
name.endsWith('.json') || name.endsWith('.xml') || name.endsWith('.yml')) {
|
||||
|
||||
const data = entry.getData();
|
||||
|
||||
if (data.includes(oldUtf8)) {
|
||||
const { buffer: patchedData, count } = this.findAndReplaceDomainUtf8(data, ORIGINAL_DOMAIN, newDomain);
|
||||
if (count > 0) {
|
||||
zip.updateFile(entry.entryName, patchedData);
|
||||
console.log(` Patched ${count} occurrences in ${name}`);
|
||||
totalCount += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCount === 0) {
|
||||
console.log('No occurrences of hytale.com found in server JAR entries');
|
||||
return { success: true, patchCount: 0, warning: 'No domain occurrences found in JAR' };
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Writing patched JAR...', 80);
|
||||
}
|
||||
|
||||
console.log('Writing patched JAR...');
|
||||
zip.writeZip(serverPath);
|
||||
|
||||
this.markAsPatched(serverPath);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Server patching complete', 100);
|
||||
}
|
||||
|
||||
console.log(`Successfully patched ${totalCount} occurrences in server`);
|
||||
console.log('=== Server Patching Complete ===');
|
||||
|
||||
return { success: true, patchCount: totalCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the client binary path based on platform
|
||||
*/
|
||||
findClientPath(gameDir) {
|
||||
const candidates = [];
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
candidates.push(path.join(gameDir, 'Client', 'Hytale.app', 'Contents', 'MacOS', 'HytaleClient'));
|
||||
candidates.push(path.join(gameDir, 'Client', 'HytaleClient'));
|
||||
} else if (process.platform === 'win32') {
|
||||
candidates.push(path.join(gameDir, 'Client', 'HytaleClient.exe'));
|
||||
} else {
|
||||
candidates.push(path.join(gameDir, 'Client', 'HytaleClient'));
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
findServerPath(gameDir) {
|
||||
const candidates = [
|
||||
path.join(gameDir, 'Server', 'HytaleServer.jar'),
|
||||
path.join(gameDir, 'Server', 'server.jar')
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure both client and server are patched before launching
|
||||
* @param {string} gameDir - Path to the game directory
|
||||
* @param {function} progressCallback - Optional callback for progress updates
|
||||
*/
|
||||
async ensureClientPatched(gameDir, progressCallback) {
|
||||
const results = {
|
||||
client: null,
|
||||
server: null,
|
||||
success: true
|
||||
};
|
||||
|
||||
const clientPath = this.findClientPath(gameDir);
|
||||
if (clientPath) {
|
||||
if (progressCallback) {
|
||||
progressCallback('Patching client binary...', 10);
|
||||
}
|
||||
results.client = await this.patchClient(clientPath, (msg, pct) => {
|
||||
if (progressCallback) {
|
||||
progressCallback(`Client: ${msg}`, pct ? pct / 2 : null);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn('Could not find HytaleClient binary');
|
||||
results.client = { success: false, error: 'Client binary not found' };
|
||||
}
|
||||
|
||||
const serverPath = this.findServerPath(gameDir);
|
||||
if (serverPath) {
|
||||
if (progressCallback) {
|
||||
progressCallback('Patching server JAR...', 50);
|
||||
}
|
||||
results.server = await this.patchServer(serverPath, (msg, pct) => {
|
||||
if (progressCallback) {
|
||||
progressCallback(`Server: ${msg}`, pct ? 50 + pct / 2 : null);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn('Could not find HytaleServer.jar');
|
||||
results.server = { success: false, error: 'Server JAR not found' };
|
||||
}
|
||||
|
||||
results.success = (results.client && results.client.success) || (results.server && results.server.success);
|
||||
results.alreadyPatched = (results.client && results.client.alreadyPatched) && (results.server && results.server.alreadyPatched);
|
||||
results.patchCount = (results.client ? results.client.patchCount || 0 : 0) + (results.server ? results.server.patchCount || 0 : 0);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Patching complete', 100);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClientPatcher();
|
||||
@@ -2,42 +2,150 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const axios = require('axios');
|
||||
|
||||
async function downloadFile(url, dest, progressCallback) {
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
responseType: 'stream',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Referer': 'https://launcher.hytale.com/'
|
||||
async function downloadFile(url, dest, progressCallback, maxRetries = 3) {
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`Download attempt ${attempt + 1}/${maxRetries} for ${url}`);
|
||||
|
||||
if (attempt > 0 && progressCallback) {
|
||||
progressCallback(`Retry ${attempt}/${maxRetries - 1}...`, null, null, null, null);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000 * attempt)); // Délai progressif
|
||||
}
|
||||
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
responseType: 'stream',
|
||||
timeout: 60000, // 60 secondes timeout
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Referer': 'https://launcher.hytale.com/',
|
||||
'Connection': 'keep-alive'
|
||||
},
|
||||
// Configuration Axios pour la robustesse réseau
|
||||
validateStatus: function (status) {
|
||||
return status >= 200 && status < 300;
|
||||
},
|
||||
// Retry configuration
|
||||
maxRedirects: 5,
|
||||
// Network resilience
|
||||
family: 4 // Force IPv4
|
||||
});
|
||||
|
||||
const totalSize = parseInt(response.headers['content-length'], 10);
|
||||
let downloaded = 0;
|
||||
let lastProgressTime = Date.now();
|
||||
const startTime = Date.now();
|
||||
|
||||
// Nettoyer le fichier de destination s'il existe
|
||||
if (fs.existsSync(dest)) {
|
||||
fs.unlinkSync(dest);
|
||||
}
|
||||
|
||||
const writer = fs.createWriteStream(dest);
|
||||
let downloadStalled = false;
|
||||
let stalledTimeout = null;
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
downloaded += chunk.length;
|
||||
const now = Date.now();
|
||||
|
||||
// Reset stalled timer on data received
|
||||
if (stalledTimeout) {
|
||||
clearTimeout(stalledTimeout);
|
||||
}
|
||||
|
||||
// Set new stalled timer (30 seconds without data = stalled)
|
||||
stalledTimeout = setTimeout(() => {
|
||||
downloadStalled = true;
|
||||
writer.destroy();
|
||||
response.data.destroy();
|
||||
}, 30000);
|
||||
|
||||
if (progressCallback && totalSize > 0 && (now - lastProgressTime > 100)) { // Update every 100ms max
|
||||
const percent = Math.min(100, Math.max(0, (downloaded / totalSize) * 100));
|
||||
const elapsed = (now - startTime) / 1000;
|
||||
const speed = elapsed > 0 ? downloaded / elapsed : 0;
|
||||
progressCallback(null, percent, speed, downloaded, totalSize);
|
||||
lastProgressTime = now;
|
||||
}
|
||||
});
|
||||
|
||||
response.data.on('error', (error) => {
|
||||
if (stalledTimeout) {
|
||||
clearTimeout(stalledTimeout);
|
||||
}
|
||||
console.error(`Stream error on attempt ${attempt + 1}:`, error.code || error.message);
|
||||
writer.destroy();
|
||||
});
|
||||
|
||||
response.data.pipe(writer);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
writer.on('finish', () => {
|
||||
if (stalledTimeout) {
|
||||
clearTimeout(stalledTimeout);
|
||||
}
|
||||
if (!downloadStalled) {
|
||||
console.log(`Download completed successfully on attempt ${attempt + 1}`);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('Download stalled'));
|
||||
}
|
||||
});
|
||||
|
||||
writer.on('error', (error) => {
|
||||
if (stalledTimeout) {
|
||||
clearTimeout(stalledTimeout);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
response.data.on('error', (error) => {
|
||||
if (stalledTimeout) {
|
||||
clearTimeout(stalledTimeout);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
// Si on arrive ici, le téléchargement a réussi
|
||||
return;
|
||||
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
console.error(`Download attempt ${attempt + 1} failed:`, error.code || error.message);
|
||||
|
||||
// Nettoyer le fichier partiel en cas d'erreur
|
||||
if (fs.existsSync(dest)) {
|
||||
try {
|
||||
fs.unlinkSync(dest);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Could not cleanup partial file:', cleanupError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier si c'est une erreur réseau que l'on peut retry
|
||||
const retryableErrors = ['ECONNRESET', 'ENOTFOUND', 'ECONNREFUSED', 'ETIMEDOUT', 'ESOCKETTIMEDOUT', 'EPROTO'];
|
||||
const isRetryable = retryableErrors.includes(error.code) ||
|
||||
error.message.includes('timeout') ||
|
||||
error.message.includes('stalled') ||
|
||||
(error.response && error.response.status >= 500);
|
||||
|
||||
if (!isRetryable || attempt === maxRetries - 1) {
|
||||
console.error(`Non-retryable error or max retries reached: ${error.code || error.message}`);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`Retryable error detected, will retry in ${2000 * (attempt + 1)}ms...`);
|
||||
}
|
||||
});
|
||||
|
||||
const totalSize = parseInt(response.headers['content-length'], 10);
|
||||
let downloaded = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
const writer = fs.createWriteStream(dest);
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
downloaded += chunk.length;
|
||||
if (progressCallback && totalSize > 0) {
|
||||
const percent = Math.min(100, Math.max(0, (downloaded / totalSize) * 100));
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
const speed = elapsed > 0 ? downloaded / elapsed : 0;
|
||||
progressCallback(null, percent, speed, downloaded, totalSize);
|
||||
}
|
||||
});
|
||||
|
||||
response.data.pipe(writer);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
writer.on('finish', resolve);
|
||||
writer.on('error', reject);
|
||||
response.data.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Download failed after ${maxRetries} attempts. Last error: ${lastError?.code || lastError?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
function findHomePageUIPath(gameLatest) {
|
||||
|
||||
Reference in New Issue
Block a user