diff --git a/backend/managers/differentialUpdateManager.js b/backend/managers/differentialUpdateManager.js index 4491bb0..cd7797e 100644 --- a/backend/managers/differentialUpdateManager.js +++ b/backend/managers/differentialUpdateManager.js @@ -3,7 +3,7 @@ const path = require('path'); const { execFile } = require('child_process'); const { downloadFile, retryDownload } = require('../utils/fileManager'); const { getOS, getArch } = require('../utils/platformUtils'); -const { validateChecksum, extractVersionDetails, getInstalledClientVersion, getUpdatePlan, extractVersionNumber } = require('../services/versionManager'); +const { validateChecksum, extractVersionDetails, getInstalledClientVersion, getUpdatePlan, extractVersionNumber, getAllMirrorUrls, getPatchesBaseUrl } = require('../services/versionManager'); const { installButler } = require('./butlerManager'); const { GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths'); const { saveVersionClient } = require('../core/config'); @@ -31,15 +31,62 @@ async function acquireGameArchive(downloadUrl, targetPath, checksum, progressCal console.log(`Downloading game archive from: ${downloadUrl}`); - try { - if (allowRetry) { - await retryDownload(downloadUrl, targetPath, progressCallback); - } else { - await downloadFile(downloadUrl, targetPath, progressCallback); + // Try primary URL first, then mirror URLs on timeout/connection failure + const mirrors = await getAllMirrorUrls(); + const primaryBase = await getPatchesBaseUrl(); + const urlsToTry = [downloadUrl]; + + // Build mirror URLs by replacing the base URL + for (const mirror of mirrors) { + if (mirror !== primaryBase && downloadUrl.startsWith(primaryBase)) { + const mirrorUrl = downloadUrl.replace(primaryBase, mirror); + if (!urlsToTry.includes(mirrorUrl)) { + urlsToTry.push(mirrorUrl); + } } - } catch (error) { - const enhancedError = new Error(`Archive download failed: ${error.message}`); - enhancedError.originalError = error; + } + + let lastError; + for (let i = 0; i < urlsToTry.length; i++) { + const url = urlsToTry[i]; + try { + if (i > 0) { + console.log(`[Download] Trying mirror ${i}: ${url}`); + if (progressCallback) { + progressCallback(`Trying alternative mirror (${i}/${urlsToTry.length - 1})...`, 0, null, null, null); + } + // Clean up partial download from previous attempt + if (fs.existsSync(targetPath)) { + try { fs.unlinkSync(targetPath); } catch (e) {} + } + } + if (allowRetry) { + await retryDownload(url, targetPath, progressCallback); + } else { + await downloadFile(url, targetPath, progressCallback); + } + lastError = null; + break; // Success + } catch (error) { + lastError = error; + const isConnectionError = error.message && ( + error.message.includes('ETIMEDOUT') || + error.message.includes('ECONNREFUSED') || + error.message.includes('ECONNABORTED') || + error.message.includes('timeout') + ); + if (isConnectionError && i < urlsToTry.length - 1) { + console.warn(`[Download] Connection failed (${error.message}), will try mirror...`); + continue; + } + // Non-connection error or last mirror — throw + break; + } + } + + if (lastError) { + const enhancedError = new Error(`Archive download failed: ${lastError.message}`); + enhancedError.originalError = lastError; enhancedError.downloadUrl = downloadUrl; enhancedError.targetPath = targetPath; throw enhancedError; diff --git a/backend/services/versionManager.js b/backend/services/versionManager.js index f31e020..6957821 100644 --- a/backend/services/versionManager.js +++ b/backend/services/versionManager.js @@ -13,6 +13,12 @@ const PATCHES_CONFIG_SOURCES = [ ]; const HARDCODED_FALLBACK = 'https://dl.vboro.de/patches'; +// Non-Cloudflare mirrors for users where Cloudflare IPs are blocked (Russia, Ukraine, etc.) +// These redirect to MEGA S3 which is not behind Cloudflare +const NON_CF_MIRRORS = [ + 'https://htdwnldsan.top/patches', // Direct IP VPS → MEGA redirect +]; + // Fallback: latest known build number if manifest is unreachable const FALLBACK_LATEST_BUILD = 11; @@ -167,7 +173,18 @@ async function getPatchesBaseUrl() { } /** - * Fetch the mirror manifest + * Get all available mirror base URLs (primary + non-Cloudflare fallbacks) + * Used by download logic to retry on different mirrors when primary is blocked + */ +async function getAllMirrorUrls() { + const primary = await getPatchesBaseUrl(); + // Deduplicate: don't include mirrors that match primary + const mirrors = NON_CF_MIRRORS.filter(m => m !== primary); + return [primary, ...mirrors]; +} + +/** + * Fetch the mirror manifest — tries primary URL first, then non-Cloudflare mirrors */ async function fetchMirrorManifest() { const now = Date.now(); @@ -177,31 +194,48 @@ async function fetchMirrorManifest() { return manifestCache; } - const baseUrl = await getPatchesBaseUrl(); - const manifestUrl = `${baseUrl}/manifest.json`; + const mirrors = await getAllMirrorUrls(); - try { - console.log('[Mirror] Fetching manifest from:', manifestUrl); - const response = await axios.get(manifestUrl, { - timeout: 15000, - headers: { 'User-Agent': 'Hytale-F2P-Launcher' } - }); + for (let i = 0; i < mirrors.length; i++) { + const baseUrl = mirrors[i]; + const manifestUrl = `${baseUrl}/manifest.json`; + try { + console.log(`[Mirror] Fetching manifest from: ${manifestUrl}`); + const response = await axios.get(manifestUrl, { + timeout: 15000, + maxRedirects: 5, + headers: { 'User-Agent': 'Hytale-F2P-Launcher' } + }); - if (response.data && response.data.files) { - manifestCache = response.data; - manifestCacheTime = now; - console.log('[Mirror] Manifest fetched successfully'); - return response.data; + if (response.data && response.data.files) { + manifestCache = response.data; + manifestCacheTime = now; + // If a non-primary mirror worked, switch to it for downloads too + if (i > 0) { + console.log(`[Mirror] Primary unreachable, switching to mirror: ${baseUrl}`); + patchesBaseUrl = baseUrl; + patchesConfigTime = now; + saveDiskCache(baseUrl); + } + console.log('[Mirror] Manifest fetched successfully'); + return response.data; + } + throw new Error('Invalid manifest structure'); + } catch (error) { + const isTimeout = error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED' || error.message.includes('timeout'); + console.error(`[Mirror] Error fetching manifest from ${baseUrl}: ${error.message}${isTimeout ? ' (Cloudflare may be blocked)' : ''}`); + if (i < mirrors.length - 1) { + console.log(`[Mirror] Trying next mirror...`); + } } - throw new Error('Invalid manifest structure'); - } catch (error) { - console.error('[Mirror] Error fetching manifest:', error.message); - if (manifestCache) { - console.log('[Mirror] Using expired cache'); - return manifestCache; - } - throw error; } + + // All mirrors failed — use cached manifest if available + if (manifestCache) { + console.log('[Mirror] All mirrors failed, using expired cache'); + return manifestCache; + } + throw new Error('All mirrors failed and no cached manifest available'); } /** @@ -499,5 +533,6 @@ module.exports = { extractVersionNumber, getPlatformPatches, findOptimalPatchPath, - getPatchesBaseUrl + getPatchesBaseUrl, + getAllMirrorUrls }; diff --git a/package.json b/package.json index 606cdaa..46d7432 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hytale-f2p-launcher", - "version": "2.3.6", + "version": "2.3.7", "description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support", "homepage": "https://git.sanhost.net/sanasol/hytale-f2p", "main": "main.js",