feat: auto-resume download process & auto-retry if disconnected (#143)

This commit is contained in:
Fazri Gading
2026-01-25 01:36:20 +08:00
committed by GitHub
parent a6f716c61b
commit a7d0523186
7 changed files with 1503 additions and 247 deletions

View File

@@ -3,15 +3,15 @@ const path = require('path');
const { execFile } = require('child_process');
const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursive, GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
const { getOS, getArch } = require('../utils/platformUtils');
const { downloadFile } = require('../utils/fileManager');
const { getLatestClientVersion } = require('../services/versionManager');
const { downloadFile, retryDownload, retryStalledDownload, MAX_AUTOMATIC_STALL_RETRIES } = require('../utils/fileManager');
const { getLatestClientVersion, getInstalledClientVersion } = require('../services/versionManager');
const { installButler } = require('./butlerManager');
const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFileManager');
const { saveUsername, saveInstallPath, loadJavaPath, CONFIG_FILE, loadConfig, loadVersionBranch, saveVersionClient, loadVersionClient } = require('../core/config');
const { resolveJavaPath, detectSystemJava, downloadJRE, getJavaExec, getBundledJavaPath } = require('./javaManager');
const userDataBackup = require('../utils/userDataBackup');
async function downloadPWR(branch = 'release', fileName = '4.pwr', progressCallback, cacheDir = CACHE_DIR) {
async function downloadPWR(branch = 'release', fileName = '4.pwr', progressCallback, cacheDir = CACHE_DIR, manualRetry = false) {
const osName = getOS();
const arch = getArch();
@@ -20,42 +20,142 @@ async function downloadPWR(branch = 'release', fileName = '4.pwr', progressCallb
}
const url = `https://game-patches.hytale.com/patches/${osName}/${arch}/${branch}/0/${fileName}`;
const dest = path.join(cacheDir, `${branch}_${fileName}`);
// Check if file exists and validate it
if (fs.existsSync(dest)) {
if (fs.existsSync(dest) && !manualRetry) {
console.log('PWR file found in cache:', dest);
// Validate file size (PWR files should be > 1MB)
// Validate file size (PWR files should be > 1MB and >= 1.5GB for complete downloads)
const stats = fs.statSync(dest);
if (stats.size < 1024 * 1024) {
console.log('Cached PWR file seems corrupted (too small), re-downloading...');
fs.unlinkSync(dest);
} else {
return dest;
return false;
}
// Check if file is under 1.5 GB (incomplete download)
const sizeInMB = stats.size / 1024 / 1024;
if (sizeInMB < 1500) {
console.log(`[PWR Validation] File appears incomplete: ${sizeInMB.toFixed(2)} MB < 1.5 GB`);
return false;
}
}
console.log('Fetching PWR patch file:', url);
await downloadFile(url, dest, progressCallback);
// Validate downloaded file
try {
if (manualRetry) {
await retryDownload(url, dest, progressCallback);
} else {
await downloadFile(url, dest, progressCallback);
}
} catch (error) {
// Check for automatic stall retry conditions (only for stall errors, not manual retries)
if (!manualRetry &&
error.message &&
error.message.includes('stalled') &&
error.canRetry !== false && // Explicitly check it's not false
(!error.retryState || error.retryState.automaticStallRetries < MAX_AUTOMATIC_STALL_RETRIES)) {
console.log(`[PWR] Automatic stall retry triggered (${(error.retryState && error.retryState.automaticStallRetries || 0) + 1}/${MAX_AUTOMATIC_STALL_RETRIES})`);
try {
await retryStalledDownload(url, dest, progressCallback, error);
console.log('[PWR] Automatic stall retry successful');
// After successful automatic retry, continue with normal flow - the file should be valid now
const retryStats = fs.statSync(dest);
console.log(`PWR file downloaded (auto-retry), size: ${(retryStats.size / 1024 / 1024).toFixed(2)} MB`);
if (!validatePWRFile(dest)) {
console.log(`[PWR Validation] PWR file validation failed after auto-retry, deleting corrupted file: ${dest}`);
fs.unlinkSync(dest);
throw new Error('Downloaded PWR file is corrupted or invalid after automatic retry. Please retry manually');
}
} catch (retryError) {
console.error('[PWR] Automatic stall retry failed:', retryError.message);
// Create enhanced error with updated retry state
const enhancedError = new Error(`PWR download failed after automatic retries: ${retryError.message}`);
enhancedError.originalError = retryError;
enhancedError.retryState = retryError.retryState || error.retryState || null;
enhancedError.canRetry = true; // Still allow manual retry
enhancedError.pwrUrl = url;
enhancedError.pwrDest = dest;
enhancedError.branch = branch;
enhancedError.fileName = fileName;
enhancedError.cacheDir = cacheDir;
enhancedError.automaticRetriesExhausted = true;
throw enhancedError;
}
}
// Enhanced error handling for retry UI (non-stall errors or exhausted automatic retries)
const enhancedError = new Error(`PWR download failed: ${error.message}`);
enhancedError.originalError = error;
enhancedError.retryState = error.retryState || null;
enhancedError.canRetry = error.isConnectionLost ? false : (error.canRetry !== false); // Don't allow retry for connection lost
enhancedError.pwrUrl = url;
enhancedError.pwrDest = dest;
enhancedError.branch = branch;
enhancedError.fileName = fileName;
enhancedError.cacheDir = cacheDir;
enhancedError.isConnectionLost = error.isConnectionLost || false;
console.log(`[PWR] Error handling:`, {
message: enhancedError.message,
isConnectionLost: enhancedError.isConnectionLost,
canRetry: enhancedError.canRetry,
retryState: enhancedError.retryState
});
throw enhancedError;
}
// Enhanced PWR file validation
const stats = fs.statSync(dest);
console.log(`PWR file downloaded, size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
if (stats.size < 1024 * 1024) {
if (!validatePWRFile(dest)) {
console.log(`[PWR Validation] PWR file validation failed, deleting corrupted file: ${dest}`);
fs.unlinkSync(dest);
throw new Error('Downloaded PWR file is corrupted (file too small)');
throw new Error('Downloaded PWR file is corrupted or invalid. Please retry');
}
console.log('PWR saved to:', dest);
console.log(`[PWR Validation] PWR file validation passed: ${dest}`);
return dest;
}
async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR) {
// Manual retry function for PWR downloads
async function retryPWRDownload(branch, fileName, progressCallback, cacheDir = CACHE_DIR) {
console.log('Initiating manual PWR retry...');
return await downloadPWR(branch, fileName, progressCallback, cacheDir, true);
}
async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR, branch = 'release', cacheDir = CACHE_DIR) {
console.log(`[Butler] Starting PWR application with:`);
console.log(`[Butler] - PWR file: ${pwrFile}`);
console.log(`[Butler] - Staging dir: ${path.join(gameDir, 'staging-temp')}`);
console.log(`[Butler] - Game dir: ${gameDir}`);
console.log(`[Butler] - Branch: ${branch}`);
console.log(`[Butler] - Cache dir: ${cacheDir}`);
// Validate PWR file exists and get diagnostic info
if (!pwrFile || typeof pwrFile !== 'string' || !fs.existsSync(pwrFile)) {
throw new Error(`PWR file not found: ${pwrFile || 'undefined'}. Please retry download.`);
}
const pwrStats = fs.statSync(pwrFile);
console.log(`[Butler] PWR file size: ${(pwrStats.size / 1024 / 1024).toFixed(2)} MB`);
console.log(`[Butler] PWR file exists: ${fs.existsSync(pwrFile)}`);
const butlerPath = await installButler(toolsDir);
console.log(`[Butler] Butler path: ${butlerPath}`);
console.log(`[Butler] Butler executable: ${fs.existsSync(butlerPath)}`);
const gameLatest = gameDir;
const stagingDir = path.join(gameLatest, 'staging-temp');
@@ -66,12 +166,11 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
return;
}
if (!fs.existsSync(gameLatest)) {
fs.mkdirSync(gameLatest, { recursive: true });
}
if (!fs.existsSync(stagingDir)) {
fs.mkdirSync(stagingDir, { recursive: true });
}
// Validate and prepare directories
validateGameDirectory(gameLatest, stagingDir);
console.log(`[Butler] Game directory validated: ${gameLatest}`);
console.log(`[Butler] Staging directory validated: ${stagingDir}`);
if (progressCallback) {
progressCallback('Installing game patch...', null, null, null, null);
@@ -95,6 +194,8 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
gameLatest
];
console.log(`[Butler] Executing command: ${butlerPath} ${args.join(' ')}`);
try {
await new Promise((resolve, reject) => {
const child = execFile(butlerPath, args, {
@@ -102,32 +203,97 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
timeout: 600000
}, (error, stdout, stderr) => {
if (error) {
console.error('Butler stderr:', stderr);
console.error('Butler stdout:', stdout);
console.error('[Butler] stderr:', stderr);
console.error('[Butler] stdout:', stdout);
console.error('[Butler] error code:', error.code);
console.error('[Butler] error signal:', error.signal);
// Check for EOF error (corrupted PWR file)
if (stderr && stderr.includes('unexpected EOF')) {
// Delete corrupted PWR file
console.log('Corrupted PWR file detected, deleting:', pwrFile);
// Enhanced error pattern detection
const errorPatterns = {
'unexpected EOF': {
message: 'Corrupted PWR file detected and deleted. Please try launching the game again.',
shouldDeletePWR: true
},
'permission denied': {
message: 'Permission denied. Check file permissions and try again.',
shouldDeletePWR: false
},
'no space left': {
message: 'Insufficient disk space. Free up space and try again.',
shouldDeletePWR: false
},
'device full': {
message: 'Insufficient disk space. Free up space and try again.',
shouldDeletePWR: false
},
'already exists': {
message: 'Installation directory conflict. Clean directories and retry.',
shouldDeletePWR: false
},
'network error': {
message: 'Network error during patch installation. Please retry.',
shouldDeletePWR: false
},
'connection refused': {
message: 'Connection refused. Check network and retry.',
shouldDeletePWR: false
}
};
let enhancedMessage = `Patch installation failed: ${error.message}${stderr ? '\n' + stderr : ''}`;
let shouldDeletePWR = false;
// Check error patterns
const errorText = (stderr + ' ' + error.message).toLowerCase();
for (const [pattern, config] of Object.entries(errorPatterns)) {
if (errorText.includes(pattern)) {
enhancedMessage = config.message;
shouldDeletePWR = config.shouldDeletePWR;
console.log(`[Butler] Pattern matched: ${pattern}`);
break;
}
}
// Delete corrupted PWR file if needed
if (shouldDeletePWR) {
try {
if (fs.existsSync(pwrFile)) {
fs.unlinkSync(pwrFile);
console.log('Corrupted PWR file deleted. Please try again to re-download.');
console.log('[Butler] Corrupted PWR file deleted:', pwrFile);
}
} catch (delErr) {
console.error('Failed to delete corrupted PWR file:', delErr);
console.error('[Butler] Failed to delete corrupted PWR file:', delErr);
}
reject(new Error(`Corrupted PWR file detected and deleted. Please try launching the game again.`));
} else {
reject(new Error(`Patch installation failed: ${error.message}${stderr ? '\n' + stderr : ''}`));
}
// Enhanced error with retry context
const enhancedError = new Error(enhancedMessage);
enhancedError.canRetry = true;
enhancedError.branch = branch;
enhancedError.fileName = path.basename(pwrFile);
enhancedError.cacheDir = cacheDir;
enhancedError.butlerError = true;
enhancedError.errorCode = error.code;
enhancedError.stderr = stderr;
enhancedError.stdout = stdout;
console.log('[Butler] Enhanced error created with retry context');
reject(enhancedError);
} else {
console.log('[Butler] Patch installation completed successfully');
resolve();
}
});
});
} catch (error) {
throw error;
console.error('[Butler] Exception during Butler execution:', error);
const enhancedError = new Error(`Butler execution failed: ${error.message}`);
enhancedError.canRetry = true;
enhancedError.branch = branch;
enhancedError.fileName = path.basename(pwrFile);
enhancedError.cacheDir = cacheDir;
enhancedError.butlerError = true;
throw enhancedError;
}
if (fs.existsSync(stagingDir)) {
@@ -191,7 +357,7 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
progressCallback('Extracting new files...', 60, null, null, null);
}
await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir);
await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir, branch, cacheDir);
if (progressCallback) {
progressCallback('Replacing game files...', 80, null, null, null);
@@ -369,8 +535,28 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver
console.log(`Installing game files for branch: ${branch}...`);
const latestVersion = await getLatestClientVersion(branch);
const pwrFile = await downloadPWR(branch, latestVersion, progressCallback, customCacheDir);
await applyPWR(pwrFile, progressCallback, customGameDir, customToolsDir);
let pwrFile;
try {
pwrFile = await downloadPWR(branch, latestVersion, progressCallback, customCacheDir);
// If downloadPWR returns false, it means the file doesn't exist or is invalid
// We should retry the download with a manual retry flag
if (!pwrFile) {
console.log('[Install] PWR file not found or invalid, attempting retry...');
pwrFile = await retryPWRDownload(branch, latestVersion, progressCallback, customCacheDir);
}
// Double-check we have a valid file path
if (!pwrFile || typeof pwrFile !== 'string') {
throw new Error(`PWR file download failed: received invalid path ${pwrFile}. Please retry download.`);
}
} catch (downloadError) {
console.error('[Install] PWR download failed:', downloadError.message);
throw downloadError; // Re-throw to be handled by the main installGame error handler
}
await applyPWR(pwrFile, progressCallback, customGameDir, customToolsDir, branch, customCacheDir);
// Save the installed version and branch to config
saveVersionClient(latestVersion);
@@ -546,8 +732,79 @@ async function repairGame(progressCallback, branchOverride = null) {
return { success: true, repaired: true };
}
// Directory validation and cleanup function
function validateGameDirectory(gameDir, stagingDir) {
try {
// Ensure game directory exists and is writable
if (!fs.existsSync(gameDir)) {
fs.mkdirSync(gameDir, { recursive: true });
console.log(`[Butler] Created game directory: ${gameDir}`);
}
// Test write permissions
const testFile = path.join(gameDir, '.permission_test');
fs.writeFileSync(testFile, 'test');
fs.unlinkSync(testFile);
console.log(`[Butler] Game directory is writable: ${gameDir}`);
// Clean and ensure staging directory
if (fs.existsSync(stagingDir)) {
console.log(`[Butler] Cleaning existing staging directory: ${stagingDir}`);
fs.rmSync(stagingDir, { recursive: true, force: true });
}
fs.mkdirSync(stagingDir, { recursive: true });
console.log(`[Butler] Created clean staging directory: ${stagingDir}`);
// Check disk space (basic check)
const freeSpace = fs.statSync(gameDir);
console.log(`[Butler] Directory validation completed successfully`);
} catch (error) {
throw new Error(`Directory validation failed: ${error.message}. Please check permissions and disk space.`);
}
}
// Enhanced PWR file validation
function validatePWRFile(filePath) {
try {
if (!fs.existsSync(filePath)) {
return false;
}
const stats = fs.statSync(filePath);
const sizeInMB = stats.size / 1024 / 1024;
if (stats.size < 1024 * 1024) {
return false;
}
// Check if file is under 1.5 GB (incomplete download)
if (sizeInMB < 1500) {
console.log(`[PWR Validation] File appears incomplete: ${sizeInMB.toFixed(2)} MB < 1.5 GB`);
return false;
}
// Basic file header validation (PWR files should have specific headers)
const buffer = fs.readFileSync(filePath, { start: 0, end: 20 });
if (buffer.length < 10) {
return false;
}
// Check for common PWR magic bytes or patterns
// This is a basic check - could be enhanced with actual PWR format specification
const header = buffer.toString('hex', 0, 10);
console.log(`[PWR Validation] File header: ${header}`);
return true;
} catch (error) {
console.error(`[PWR Validation] Error:`, error.message);
return false;
}
}
module.exports = {
downloadPWR,
retryPWRDownload,
applyPWR,
updateGameFiles,
isGameInstalled,

View File

@@ -2,16 +2,116 @@ const fs = require('fs');
const path = require('path');
const axios = require('axios');
async function downloadFile(url, dest, progressCallback, maxRetries = 3) {
// Automatic stall retry constants
const MAX_AUTOMATIC_STALL_RETRIES = 3;
const AUTOMATIC_STALL_RETRY_DELAY = 3000; // 3 seconds in milliseconds
// Network monitoring utilities using Node.js built-in methods
function checkNetworkConnection() {
return new Promise((resolve) => {
const { lookup } = require('dns');
const http = require('http');
// Try DNS lookup first (faster) - using callback version
lookup('8.8.8.8', (err) => {
if (err) {
resolve(false);
return;
}
// Try HTTP request to confirm internet connectivity
const req = http.get('http://www.google.com', { timeout: 3000 }, (res) => {
resolve(true);
res.destroy();
});
req.on('error', () => {
resolve(false);
});
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
});
}
async function downloadFile(url, dest, progressCallback, maxRetries = 5) {
let lastError = null;
let retryState = {
attempts: 0,
maxRetries: maxRetries,
canRetry: true,
lastError: null,
automaticStallRetries: 0,
isAutomaticRetry: false
};
let downloadStalled = false;
let streamCompleted = false;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
retryState.attempts = attempt + 1;
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
// Exponential backoff with jitter
const baseDelay = 2000;
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
const jitter = Math.random() * 1000;
const delay = Math.min(exponentialDelay + jitter, 30000);
progressCallback(`Retry ${attempt}/${maxRetries - 1}...`, null, null, null, null, retryState);
await new Promise(resolve => setTimeout(resolve, delay));
}
// Create AbortController for proper stream control
const controller = new AbortController();
let hasReceivedData = false;
// Smart overall timeout - only trigger if no progress for extended period
const overallTimeout = setInterval(() => {
const now = Date.now();
const timeSinceLastProgress = now - lastProgressTime;
// Only timeout if no data received for 5 minutes (300 seconds)
if (timeSinceLastProgress > 300000 && hasReceivedData) {
console.log('Download stalled for 5 minutes, aborting...');
console.log(`Download had progress before stall: ${(downloaded / 1024 / 1024).toFixed(2)} MB`);
controller.abort();
}
}, 60000); // Check every minute
// Check if we can resume existing download
let startByte = 0;
if (fs.existsSync(dest)) {
const existingStats = fs.statSync(dest);
// Only resume if file exists and is substantial (> 1MB)
if (existingStats.size > 1024 * 1024) {
startByte = existingStats.size;
console.log(`Resuming download from byte ${startByte} (${(existingStats.size / 1024 / 1024).toFixed(2)} MB already downloaded)`);
} else {
// File too small, start fresh
fs.unlinkSync(dest);
console.log('Existing file too small, starting fresh download');
}
}
const 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'
};
// Add Range header for resume capability
if (startByte > 0) {
headers['Range'] = `bytes=${startByte}-`;
}
const response = await axios({
@@ -19,16 +119,12 @@ async function downloadFile(url, dest, progressCallback, maxRetries = 3) {
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'
},
signal: controller.signal,
headers: headers,
// Configuration Axios pour la robustesse réseau
validateStatus: function (status) {
return status >= 200 && status < 300;
// Accept both 200 (full download) and 206 (partial content for resume)
return (status >= 200 && status < 300) || status === 206;
},
// Retry configuration
maxRedirects: 5,
@@ -36,117 +132,313 @@ async function downloadFile(url, dest, progressCallback, maxRetries = 3) {
family: 4 // Force IPv4
});
const totalSize = parseInt(response.headers['content-length'], 10);
let downloaded = 0;
const contentLength = response.headers['content-length'];
const totalSize = contentLength ? parseInt(contentLength, 10) + startByte : 0; // Adjust for resume
let downloaded = startByte; // Start with existing bytes
let lastProgressTime = Date.now();
const startTime = Date.now();
// Nettoyer le fichier de destination s'il existe
if (fs.existsSync(dest)) {
fs.unlinkSync(dest);
// Check network status before attempting download
try {
const isNetworkOnline = await checkNetworkConnection();
if (!isNetworkOnline) {
throw new Error('Network connection unavailable. Please check your connection and retry.');
}
} catch (networkError) {
console.error('[Network] Network check failed, proceeding anyway:', networkError.message);
// Continue with download attempt - network check failure shouldn't block
}
const writer = fs.createWriteStream(dest);
let downloadStalled = false;
const writer = fs.createWriteStream(dest, {
flags: startByte > 0 ? 'a' : 'w', // 'a' for append (resume), 'w' for write (fresh)
start: startByte > 0 ? startByte : 0
});
let streamError = null;
let stalledTimeout = null;
// Reset state for this attempt
downloadStalled = false;
streamCompleted = false;
// Enhanced stream event handling
response.data.on('data', (chunk) => {
downloaded += chunk.length;
const now = Date.now();
hasReceivedData = true; // Mark that we've received data
// Reset stalled timer on data received
// Reset simple stall timer on data received
if (stalledTimeout) {
clearTimeout(stalledTimeout);
}
// Set new stalled timer (30 seconds without data = stalled)
stalledTimeout = setTimeout(() => {
// Set new stall timer (30 seconds without data = stalled)
stalledTimeout = setTimeout(async () => {
console.log('Download stalled - checking network connectivity...');
// Check if network is actually available before retrying
try {
const isNetworkOnline = await checkNetworkConnection();
if (!isNetworkOnline) {
console.log('Network connection lost - stopping download and showing error');
downloadStalled = true;
streamError = new Error('Network connection lost. Please check your internet connection and retry.');
streamError.isConnectionLost = true;
streamError.canRetry = false;
controller.abort();
writer.destroy();
response.data.destroy();
// Immediately reject the promise to prevent hanging
setTimeout(() => promiseReject(streamError), 100);
return;
}
} catch (networkError) {
console.error('Network check failed during stall detection:', networkError.message);
}
console.log('Network available - download stalled due to slow connection, aborting for retry...');
downloadStalled = true;
streamError = new Error('Download stalled due to slow network connection. Please retry.');
controller.abort();
writer.destroy();
response.data.destroy();
// Immediately reject the promise to prevent hanging
setTimeout(() => promiseReject(streamError), 100);
}, 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);
progressCallback(null, percent, speed, downloaded, totalSize, retryState);
lastProgressTime = now;
}
});
// Enhanced stream error handling
response.data.on('error', (error) => {
// Ignore errors if it was intentionally cancelled or already handled
if (downloadStalled || streamCompleted || controller.signal.aborted) {
console.log(`Ignoring stream error after cancellation: ${error.code || error.message}`);
return;
}
if (!streamError) {
streamError = new Error(`Stream error: ${error.code || error.message}. Please retry.`);
// Check for connection lost indicators
if (error.code === 'ERR_NETWORK_CHANGED' ||
error.code === 'ERR_INTERNET_DISCONNECTED' ||
error.code === 'ERR_CONNECTION_LOST') {
streamError.isConnectionLost = true;
streamError.canRetry = false;
}
console.error(`Stream error on attempt ${attempt + 1}:`, error.code || error.message);
}
if (stalledTimeout) {
clearTimeout(stalledTimeout);
}
console.error(`Stream error on attempt ${attempt + 1}:`, error.code || error.message);
if (overallTimeout) {
clearInterval(overallTimeout);
}
writer.destroy();
});
response.data.on('close', () => {
// Only treat as error if not already handled by cancellation and writer didn't complete
if (!streamError && !streamCompleted && !downloadStalled && !controller.signal.aborted) {
// Check if writer actually completed but stream close came first
setTimeout(() => {
if (!streamCompleted) {
streamError = new Error('Stream closed unexpectedly. Please retry.');
console.log('Stream closed unexpectedly on attempt', attempt + 1);
}
}, 500); // Small delay to check if writer completes
}
if (stalledTimeout) {
clearTimeout(stalledTimeout);
}
if (overallTimeout) {
clearInterval(overallTimeout);
}
});
response.data.on('abort', () => {
// Only treat as error if not already handled by stall detection
if (!streamError && !streamCompleted && !downloadStalled) {
streamError = new Error('Download aborted due to network issue. Please retry.');
console.log('Stream aborted on attempt', attempt + 1);
}
if (stalledTimeout) {
clearTimeout(stalledTimeout);
}
});
response.data.pipe(writer);
let promiseReject = null;
await new Promise((resolve, reject) => {
// Store promise reject function for immediate use by stall timeout
promiseReject = reject;
writer.on('finish', () => {
streamCompleted = true;
console.log(`Writer finished on attempt ${attempt + 1}, downloaded: ${(downloaded / 1024 / 1024).toFixed(2)} MB`);
// Clear ALL timeouts to prevent them from firing after completion
if (stalledTimeout) {
clearTimeout(stalledTimeout);
console.log('Cleared stall timeout after writer finished');
}
if (overallTimeout) {
clearInterval(overallTimeout);
console.log('Cleared overall timeout after writer finished');
}
// Download is successful if writer finished - regardless of stream state
if (!downloadStalled) {
console.log(`Download completed successfully on attempt ${attempt + 1}`);
resolve();
} else {
reject(new Error('Download stalled'));
// Don't reject here if we already rejected due to network loss - prevents duplicate rejection
console.log('Writer finished after stall detection, ignoring...');
}
});
writer.on('error', (error) => {
// Ignore write errors if stream was intentionally cancelled
if (downloadStalled || controller.signal.aborted) {
console.log(`Ignoring writer error after cancellation: ${error.code || error.message}`);
return;
}
if (!streamError) {
streamError = new Error(`File write error: ${error.code || error.message}. Please retry.`);
console.error(`Writer error on attempt ${attempt + 1}:`, error.code || error.message);
}
if (stalledTimeout) {
clearTimeout(stalledTimeout);
}
reject(error);
if (overallTimeout) {
clearInterval(overallTimeout);
}
reject(streamError);
});
response.data.on('error', (error) => {
if (stalledTimeout) {
clearTimeout(stalledTimeout);
// Handle case where stream ends without finishing writer
response.data.on('end', () => {
if (!streamCompleted && !downloadStalled && !streamError) {
// Give a small delay for writer to finish - this is normal behavior
setTimeout(() => {
if (!streamCompleted) {
console.log('Stream ended but writer not finished - waiting longer...');
// Give more time for writer to finish - this might be slow disk I/O
setTimeout(() => {
if (!streamCompleted) {
streamError = new Error('Download incomplete. Please retry.');
reject(streamError);
}
}, 2000);
}
}, 1000);
}
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);
} catch (error) {
lastError = error;
retryState.lastError = error;
console.error(`Download attempt ${attempt + 1} failed:`, error.code || error.message);
console.error(`Error details:`, {
isConnectionLost: error.isConnectionLost,
canRetry: error.canRetry,
message: error.message,
downloadStalled: downloadStalled,
streamCompleted: streamCompleted
});
// Check if download actually completed successfully despite the error
if (fs.existsSync(dest)) {
const stats = fs.statSync(dest);
const sizeInMB = stats.size / 1024 / 1024;
console.log(`File size after error: ${sizeInMB.toFixed(2)} MB`);
// If file is substantial size (> 1.5GB), treat as success and break
if (sizeInMB >= 1500) {
console.log('File appears to be complete despite error, treating as success');
return; // Exit the retry loop successfully
}
}
// Nettoyer le fichier partiel en cas d'erreur
if (fs.existsSync(dest)) {
try {
// Enhanced file cleanup with validation
if (fs.existsSync(dest)) {
try {
// Check if file is corrupted (small or invalid) or if error is non-resumable
const partialStats = fs.statSync(dest);
const isResumableError = error.message && (
error.message.includes('stalled') ||
error.message.includes('timeout') ||
error.message.includes('network') ||
error.message.includes('aborted')
);
// Check if download appears to be complete (close to expected PWR size)
const isPossiblyComplete = partialStats.size >= 1500 * 1024 * 1024; // >= 1.5GB
if (partialStats.size < 1024 * 1024 || (!isResumableError && !isPossiblyComplete)) {
// Delete if file is too small OR error is non-resumable AND not possibly complete
console.log(`[Cleanup] Removing PWR file (${!isResumableError && !isPossiblyComplete ? 'non-resumable error' : 'too small'}): ${(partialStats.size / 1024 / 1024).toFixed(2)} MB`);
fs.unlinkSync(dest);
} catch (cleanupError) {
console.warn('Could not cleanup partial file:', cleanupError.message);
} else {
// Keep the file for resume on resumable errors or if possibly complete
console.log(`[Resume] Keeping PWR file (${isPossiblyComplete ? 'possibly complete' : 'for resume'}): ${(partialStats.size / 1024 / 1024).toFixed(2)} MB`);
}
} catch (cleanupError) {
console.warn('Could not handle 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);
// Expanded retryable error codes for better network detection
const retryableErrors = [
'ECONNRESET', 'ENOTFOUND', 'ECONNREFUSED', 'ETIMEDOUT',
'ESOCKETTIMEDOUT', 'EPROTO', 'ENETDOWN', 'EHOSTUNREACH',
'ERR_NETWORK', 'ERR_INTERNET_DISCONNECTED', 'ERR_CONNECTION_RESET',
'ERR_CONNECTION_TIMED_OUT', 'ERR_NAME_NOT_RESOLVED'
];
const isRetryable = retryableErrors.includes(error.code) ||
error.message.includes('timeout') ||
error.message.includes('stalled') ||
error.message.includes('aborted') ||
error.message.includes('network') ||
error.message.includes('connection') ||
error.message.includes('Please retry') ||
error.message.includes('corrupted') ||
error.message.includes('invalid') ||
(error.response && error.response.status >= 500);
if (!isRetryable || attempt === maxRetries - 1) {
console.error(`Non-retryable error or max retries reached: ${error.code || error.message}`);
// Respect error's canRetry property if set
const canRetry = (error.canRetry === false) ? false : isRetryable;
break;
}
if (!canRetry || attempt === maxRetries - 1) {
retryState.canRetry = false;
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...`);
console.log(`Retryable error detected, will retry...`);
}
}
throw new Error(`Download failed after ${maxRetries} attempts. Last error: ${lastError?.code || lastError?.message || 'Unknown error'}`);
// Enhanced error with retry state and user-friendly message
const detailedError = lastError?.code || lastError?.message || 'Unknown error';
const errorMessage = `Download failed after ${maxRetries} attempts. Last error: ${detailedError}. Please retry`;
const enhancedError = new Error(errorMessage);
enhancedError.retryState = retryState;
enhancedError.lastError = lastError;
enhancedError.detailedError = detailedError;
throw enhancedError;
}
function findHomePageUIPath(gameLatest) {
@@ -205,8 +497,64 @@ function findLogoPath(gameLatest) {
return searchDirectory(gameLatest);
}
// Automatic stall retry function for network stalls
async function retryStalledDownload(url, dest, progressCallback, previousError = null) {
console.log('Automatic stall retry initiated for:', url);
// Wait before retry to allow network recovery
console.log(`Waiting ${AUTOMATIC_STALL_RETRY_DELAY/1000} seconds before automatic retry...`);
await new Promise(resolve => setTimeout(resolve, AUTOMATIC_STALL_RETRY_DELAY));
try {
// Create new retryState for automatic retry
const automaticRetryState = {
attempts: 1,
maxRetries: 1,
canRetry: true,
lastError: null,
automaticStallRetries: (previousError && previousError.retryState) ? previousError.retryState.automaticStallRetries + 1 : 1,
isAutomaticRetry: true
};
// Update progress callback with automatic retry info
if (progressCallback) {
progressCallback(
`Automatic stall retry ${automaticRetryState.automaticStallRetries}/${MAX_AUTOMATIC_STALL_RETRIES}...`,
null, null, null, null, automaticRetryState
);
}
await downloadFile(url, dest, progressCallback, 1);
console.log('Automatic stall retry successful');
} catch (error) {
console.error('Automatic stall retry failed:', error.message);
throw error;
}
}
// Manual retry function for user-initiated retries
async function retryDownload(url, dest, progressCallback, previousError = null) {
console.log('Manual retry initiated for:', url);
// If we have a previous error with retry state, continue from there
let additionalRetries = 3; // Allow 3 additional manual retries
if (previousError && previousError.retryState) {
additionalRetries = Math.max(2, 5 - previousError.retryState.attempts);
}
try {
await downloadFile(url, dest, progressCallback, additionalRetries);
console.log('Manual retry successful');
} catch (error) {
console.error('Manual retry failed:', error.message);
throw error;
}
}
module.exports = {
downloadFile,
retryDownload,
retryStalledDownload,
findHomePageUIPath,
findLogoPath
};