From 0dafb17c7b3b7dd79304f6fd438cf16f364d8a4b Mon Sep 17 00:00:00 2001 From: sanasol Date: Fri, 20 Feb 2026 14:36:09 +0100 Subject: [PATCH] v2.3.1: CDN redirect gateway, fix token username bug - Migrate patch downloads to auth server redirect gateway (302 -> CDN) Allows instant CDN switching via admin panel without launcher update - Fix identity token "Player" username mismatch on fresh install Add token username verification with retry in fetchAuthTokens - Refactor versionManager to use mirror manifest via auth.sanasol.ws/patches - Add optimal patch routing (BFS) for differential updates - Add PATCH_CDN_INFRASTRUCTURE.md documentation Co-Authored-By: Claude Opus 4.6 --- backend/managers/differentialUpdateManager.js | 122 ++--- backend/managers/gameLauncher.js | 37 +- backend/managers/gameManager.js | 234 ++++++---- backend/services/versionManager.js | 440 +++++++++--------- docs/PATCH_CDN_INFRASTRUCTURE.md | 217 +++++++++ package.json | 2 +- 6 files changed, 685 insertions(+), 367 deletions(-) create mode 100644 docs/PATCH_CDN_INFRASTRUCTURE.md diff --git a/backend/managers/differentialUpdateManager.js b/backend/managers/differentialUpdateManager.js index 5df790f..4491bb0 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, canUseDifferentialUpdate, needsIntermediatePatches, getInstalledClientVersion } = require('../services/versionManager'); +const { validateChecksum, extractVersionDetails, getInstalledClientVersion, getUpdatePlan, extractVersionNumber } = require('../services/versionManager'); const { installButler } = require('./butlerManager'); const { GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths'); const { saveVersionClient } = require('../core/config'); @@ -30,7 +30,7 @@ async function acquireGameArchive(downloadUrl, targetPath, checksum, progressCal } console.log(`Downloading game archive from: ${downloadUrl}`); - + try { if (allowRetry) { await retryDownload(downloadUrl, targetPath, progressCallback); @@ -103,13 +103,13 @@ async function deployGameArchive(archivePath, destinationDir, toolsDir, progress if (error) { const cleanStderr = stderr.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim(); const cleanStdout = stdout.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim(); - + if (cleanStderr) console.error('Deployment stderr:', cleanStderr); if (cleanStdout) console.error('Deployment stdout:', cleanStdout); - + const errorText = (stderr + ' ' + error.message).toLowerCase(); let message = 'Game deployment failed'; - + if (errorText.includes('unexpected eof')) { message = 'Corrupted archive detected. Please retry download.'; if (fs.existsSync(archivePath)) { @@ -156,20 +156,20 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr console.log(`Initiating intelligent update to version ${targetVersion}`); const currentVersion = getInstalledClientVersion(); - console.log(`Current version: ${currentVersion || 'none (clean install)'}`); - console.log(`Target version: ${targetVersion}`); - console.log(`Branch: ${branch}`); + const currentBuild = extractVersionNumber(currentVersion) || 0; + const targetBuild = extractVersionNumber(targetVersion); + console.log(`Current build: ${currentBuild}, Target build: ${targetBuild}, Branch: ${branch}`); + // For non-release branches, always do full install if (branch !== 'release') { - console.log(`Pre-release branch detected - forcing full archive download`); + console.log('Pre-release branch detected - forcing full archive download'); const versionDetails = await extractVersionDetails(targetVersion, branch); - const archiveName = path.basename(versionDetails.fullUrl); - const archivePath = path.join(cacheDir, `${branch}_${archiveName}`); - + const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`); + if (progressCallback) { progressCallback('Downloading full game archive (pre-release)...', 0, null, null, null); } - + await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback); await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false); saveVersionClient(targetVersion); @@ -177,16 +177,16 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr return; } - if (!currentVersion) { + // Clean install (no current version) + if (currentBuild === 0) { console.log('No existing installation detected - downloading full archive'); const versionDetails = await extractVersionDetails(targetVersion, branch); - const archiveName = path.basename(versionDetails.fullUrl); - const archivePath = path.join(cacheDir, `${branch}_${archiveName}`); - + const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`); + if (progressCallback) { - progressCallback(`Downloading full game archive (first install - v${targetVersion})...`, 0, null, null, null); + progressCallback(`Downloading full game archive (first install - v${targetBuild})...`, 0, null, null, null); } - + await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback); await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false); saveVersionClient(targetVersion); @@ -194,59 +194,67 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr return; } - const patchesToApply = needsIntermediatePatches(currentVersion, targetVersion); - - if (patchesToApply.length === 0) { - console.log('Already at target version or invalid version sequence'); + // Already at target + if (currentBuild >= targetBuild) { + console.log('Already at target version or newer'); return; } - console.log(`Applying ${patchesToApply.length} differential patch(es): ${patchesToApply.join(' -> ')}`); + // Use mirror's update plan for optimal patch routing + try { + const plan = await getUpdatePlan(currentBuild, targetBuild, branch); + + console.log(`Applying ${plan.steps.length} patch(es): ${plan.steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')}`); + + for (let i = 0; i < plan.steps.length; i++) { + const step = plan.steps[i]; + const stepName = `${step.from}_to_${step.to}`; + const archivePath = path.join(cacheDir, `${branch}_${stepName}.pwr`); + const isDifferential = step.from !== 0; - for (let i = 0; i < patchesToApply.length; i++) { - const patchVersion = patchesToApply[i]; - const versionDetails = await extractVersionDetails(patchVersion, branch); - - const canDifferential = canUseDifferentialUpdate(getInstalledClientVersion(), versionDetails); - - if (!canDifferential || !versionDetails.differentialUrl) { - console.log(`WARNING: Differential patch not available for ${patchVersion}, using full archive`); - const archiveName = path.basename(versionDetails.fullUrl); - const archivePath = path.join(cacheDir, `${branch}_${archiveName}`); - if (progressCallback) { - progressCallback(`Downloading full archive for ${patchVersion} (${i + 1}/${patchesToApply.length})...`, 0, null, null, null); + progressCallback(`Downloading patch ${i + 1}/${plan.steps.length}: ${stepName}...`, 0, null, null, null); } - - await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback); - await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false); - } else { - console.log(`Applying differential patch: ${versionDetails.sourceVersion} -> ${patchVersion}`); - const archiveName = path.basename(versionDetails.differentialUrl); - const archivePath = path.join(cacheDir, `${branch}_patch_${archiveName}`); - + + await acquireGameArchive(step.url, archivePath, null, progressCallback); + if (progressCallback) { - progressCallback(`Applying patch ${i + 1}/${patchesToApply.length}: ${patchVersion}...`, 0, null, null, null); + progressCallback(`Applying patch ${i + 1}/${plan.steps.length}: ${stepName}...`, 50, null, null, null); } - - await acquireGameArchive(versionDetails.differentialUrl, archivePath, versionDetails.checksum, progressCallback); - await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, true); - + + await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, isDifferential); + + // Clean up patch file if (fs.existsSync(archivePath)) { try { fs.unlinkSync(archivePath); - console.log(`Cleaned up patch file: ${archiveName}`); + console.log(`Cleaned up: ${stepName}.pwr`); } catch (cleanupErr) { - console.warn(`Failed to cleanup patch file: ${cleanupErr.message}`); + console.warn(`Failed to cleanup: ${cleanupErr.message}`); } } - } - - saveVersionClient(patchVersion); - console.log(`Patch ${patchVersion} applied successfully (${i + 1}/${patchesToApply.length})`); - } - console.log(`Update completed successfully. Version ${targetVersion} is now installed.`); + saveVersionClient(`v${step.to}`); + console.log(`Patch ${stepName} applied (${i + 1}/${plan.steps.length})`); + } + + console.log(`Update completed. Version ${targetVersion} is now installed.`); + } catch (planError) { + console.error('Update plan failed:', planError.message); + console.log('Falling back to full archive download'); + + // Fallback: full install + const versionDetails = await extractVersionDetails(targetVersion, branch); + const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`); + + if (progressCallback) { + progressCallback(`Downloading full game archive (fallback)...`, 0, null, null, null); + } + + await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback); + await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false); + saveVersionClient(targetVersion); + } } async function ensureGameInstalled(targetVersion, branch = 'release', progressCallback, gameDir = GAME_DIR, cacheDir = CACHE_DIR, toolsDir = TOOLS_DIR) { diff --git a/backend/managers/gameLauncher.js b/backend/managers/gameLauncher.js index ebf5707..22ec0a1 100644 --- a/backend/managers/gameLauncher.js +++ b/backend/managers/gameLauncher.js @@ -61,12 +61,39 @@ async function fetchAuthTokens(uuid, name) { } const data = await response.json(); - console.log('Auth tokens received from server'); + const identityToken = data.IdentityToken || data.identityToken; + const sessionToken = data.SessionToken || data.sessionToken; - return { - identityToken: data.IdentityToken || data.identityToken, - sessionToken: data.SessionToken || data.sessionToken - }; + // Verify the identity token has the correct username + // This catches cases where the auth server defaults to "Player" + try { + const parts = identityToken.split('.'); + if (parts.length >= 2) { + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); + if (payload.username && payload.username !== name && name !== 'Player') { + console.warn(`[Auth] Token username mismatch: token has "${payload.username}", expected "${name}". Retrying...`); + // Retry once with explicit name + const retryResponse = 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 (retryResponse.ok) { + const retryData = await retryResponse.json(); + console.log('[Auth] Retry successful'); + return { + identityToken: retryData.IdentityToken || retryData.identityToken, + sessionToken: retryData.SessionToken || retryData.sessionToken + }; + } + } + } + } catch (verifyErr) { + console.warn('[Auth] Token verification skipped:', verifyErr.message); + } + + console.log('Auth tokens received from server'); + return { identityToken, sessionToken }; } catch (error) { console.error('Failed to fetch auth tokens:', error.message); // Fallback to local generation if server unavailable diff --git a/backend/managers/gameManager.js b/backend/managers/gameManager.js index c60e912..873a1ca 100644 --- a/backend/managers/gameManager.js +++ b/backend/managers/gameManager.js @@ -5,7 +5,7 @@ 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'); -const { getLatestClientVersion, getInstalledClientVersion } = require('../services/versionManager'); +const { getLatestClientVersion, getInstalledClientVersion, getUpdatePlan, extractVersionNumber } = require('../services/versionManager'); const { FORCE_CLEAN_INSTALL_VERSION, CLEAN_INSTALL_TEST_VERSION } = require('../core/testConfig'); const { installButler } = require('./butlerManager'); const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFileManager'); @@ -64,7 +64,7 @@ async function safeRemoveDirectory(dirPath, maxRetries = 3) { } } -async function downloadPWR(branch = 'release', fileName = 'v8', progressCallback, cacheDir = CACHE_DIR, manualRetry = false) { +async function downloadPWR(branch = 'release', fileName = 'v8', progressCallback, cacheDir = CACHE_DIR, manualRetry = false, directUrl = null) { const osName = getOS(); const arch = getArch(); @@ -72,43 +72,38 @@ async function downloadPWR(branch = 'release', fileName = 'v8', progressCallback throw new Error('Hytale x86_64 Intel Mac Support has not been released yet. Please check back later.'); } - const { getPWRUrlFromNewAPI } = require('../services/versionManager'); - let url; - let isUsingNewAPI = false; - - try { - console.log(`[DownloadPWR] Fetching URL from new API for branch: ${branch}, version: ${fileName}`); - url = await getPWRUrlFromNewAPI(branch, fileName); - isUsingNewAPI = true; - console.log(`[DownloadPWR] Using new API URL: ${url}`); - } catch (error) { - console.error(`[DownloadPWR] Failed to get URL from new API: ${error.message}`); - console.log(`[DownloadPWR] Falling back to old URL format`); - url = `https://game-patches.hytale.com/patches/${osName}/${arch}/${branch}/0/${fileName}.pwr`; + + if (directUrl) { + url = directUrl; + console.log(`[DownloadPWR] Using direct URL: ${url}`); + } else { + const { getPWRUrl } = require('../services/versionManager'); + try { + console.log(`[DownloadPWR] Fetching mirror URL for branch: ${branch}, version: ${fileName}`); + url = await getPWRUrl(branch, fileName); + console.log(`[DownloadPWR] Mirror URL: ${url}`); + } catch (error) { + console.error(`[DownloadPWR] Failed to get mirror URL: ${error.message}`); + const { MIRROR_BASE_URL } = require('../services/versionManager'); + url = `${MIRROR_BASE_URL}/${osName}/${arch}/${branch}/0_to_${extractVersionNumber(fileName)}.pwr`; + console.log(`[DownloadPWR] Fallback URL: ${url}`); + } } const dest = path.join(cacheDir, `${branch}_${fileName}.pwr`); // Check if file exists and validate it if (fs.existsSync(dest) && !manualRetry) { - console.log('PWR file found in cache:', dest); - - // Validate file size (PWR files should be > 1MB and >= 1.5GB for complete downloads) const stats = fs.statSync(dest); - if (stats.size < 1024 * 1024) { - 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; + if (stats.size > 1024 * 1024) { + console.log(`[PWR] Using cached file: ${dest} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`); + return dest; } + console.log(`[PWR] Cached file too small (${stats.size} bytes), re-downloading`); } - console.log(`Fetching PWR patch file from ${isUsingNewAPI ? 'NEW API' : 'old API'}:`, url); + console.log(`[DownloadPWR] Downloading from: ${url}`); try { if (manualRetry) { @@ -203,7 +198,7 @@ async function retryPWRDownload(branch, fileName, progressCallback, cacheDir = C return await downloadPWR(branch, fileName, progressCallback, cacheDir, true); } -async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR, branch = 'release', cacheDir = CACHE_DIR) { +async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR, branch = 'release', cacheDir = CACHE_DIR, skipExistingCheck = false) { console.log(`[Butler] Starting PWR application with:`); console.log(`[Butler] - PWR file: ${pwrFile}`); console.log(`[Butler] - Staging dir: ${path.join(gameDir, 'staging-temp')}`); @@ -227,11 +222,12 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir const gameLatest = gameDir; const stagingDir = path.join(gameLatest, 'staging-temp'); - const clientPath = findClientPath(gameLatest); - - if (clientPath) { - console.log('Game files detected, skipping patch installation.'); - return; + if (!skipExistingCheck) { + const clientPath = findClientPath(gameLatest); + if (clientPath) { + console.log('Game files detected, skipping patch installation.'); + return; + } } // Validate and prepare directories @@ -412,57 +408,118 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR, } console.log(`Updating game files to version: ${newVersion} (branch: ${branch})`); - tempUpdateDir = path.join(gameDir, '..', 'temp_update'); + // Determine update strategy: intermediate patches vs full reinstall + const currentVersion = loadVersionClient(); + const currentBuild = extractVersionNumber(currentVersion) || 0; + const targetBuild = extractVersionNumber(newVersion); - if (fs.existsSync(tempUpdateDir)) { - fs.rmSync(tempUpdateDir, { recursive: true, force: true }); - } - fs.mkdirSync(tempUpdateDir, { recursive: true }); + let useIntermediatePatches = false; + let updatePlan = null; - if (progressCallback) { - progressCallback('Downloading new game version...', 20, null, null, null); - } - - const pwrFile = await downloadPWR(branch, newVersion, progressCallback, cacheDir); - - if (progressCallback) { - progressCallback('Extracting new files...', 60, null, null, null); - } - - await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir, branch, cacheDir); - // Delete PWR file from cache after successful update - try { - if (fs.existsSync(pwrFile)) { - fs.unlinkSync(pwrFile); - console.log('[UpdateGameFiles] PWR file deleted from cache after successful update:', pwrFile); + if (currentBuild > 0 && currentBuild < targetBuild) { + try { + updatePlan = await getUpdatePlan(currentBuild, targetBuild, branch); + useIntermediatePatches = !updatePlan.isFullInstall; + if (useIntermediatePatches) { + const totalMB = (updatePlan.totalSize / 1024 / 1024).toFixed(0); + console.log(`[UpdateGameFiles] Using intermediate patches: ${updatePlan.steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')} (${totalMB} MB)`); + } + } catch (planError) { + console.warn('[UpdateGameFiles] Could not get update plan, falling back to full install:', planError.message); } - } catch (delErr) { - console.warn('[UpdateGameFiles] Failed to delete PWR file from cache:', delErr.message); - } - if (progressCallback) { - progressCallback('Replacing game files...', 80, null, null, null); } - if (fs.existsSync(gameDir)) { - console.log('Removing old game files...'); - let retries = 3; - while (retries > 0) { + if (useIntermediatePatches && updatePlan) { + // Apply intermediate patches directly to game dir + for (let i = 0; i < updatePlan.steps.length; i++) { + const step = updatePlan.steps[i]; + const stepName = `${step.from}_to_${step.to}`; + + if (progressCallback) { + const progress = 20 + Math.round((i / updatePlan.steps.length) * 60); + progressCallback(`Downloading patch ${i + 1}/${updatePlan.steps.length} (${stepName})...`, progress, null, null, null); + } + + const pwrFile = await downloadPWR(branch, stepName, progressCallback, cacheDir, false, step.url); + + if (!pwrFile) { + throw new Error(`Failed to download patch ${stepName}`); + } + + if (progressCallback) { + progressCallback(`Applying patch ${i + 1}/${updatePlan.steps.length} (${stepName})...`, null, null, null, null); + } + + await applyPWR(pwrFile, progressCallback, gameDir, toolsDir, branch, cacheDir, true); + + // Clean up PWR file from cache try { - fs.rmSync(gameDir, { recursive: true, force: true }); - break; - } catch (err) { - if ((err.code === 'EPERM' || err.code === 'EBUSY') && retries > 0) { - retries--; - console.log(`[UpdateGameFiles] Removal failed with ${err.code}, retrying in 1s... (${retries} retries left)`); - await new Promise(resolve => setTimeout(resolve, 1000)); - } else { - throw err; + if (fs.existsSync(pwrFile)) { + fs.unlinkSync(pwrFile); + } + } catch (delErr) { + console.warn('[UpdateGameFiles] Failed to delete PWR from cache:', delErr.message); + } + + // Save intermediate version so we can resume if interrupted + saveVersionClient(`v${step.to}`); + console.log(`[UpdateGameFiles] Applied patch ${stepName} (${i + 1}/${updatePlan.steps.length})`); + } + } else { + // Full install: download 0->target, apply to temp dir, swap + tempUpdateDir = path.join(gameDir, '..', 'temp_update'); + + if (fs.existsSync(tempUpdateDir)) { + fs.rmSync(tempUpdateDir, { recursive: true, force: true }); + } + fs.mkdirSync(tempUpdateDir, { recursive: true }); + + if (progressCallback) { + progressCallback('Downloading new game version...', 20, null, null, null); + } + + const pwrFile = await downloadPWR(branch, newVersion, progressCallback, cacheDir); + + if (progressCallback) { + progressCallback('Extracting new files...', 60, null, null, null); + } + + await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir, branch, cacheDir); + + try { + if (fs.existsSync(pwrFile)) { + fs.unlinkSync(pwrFile); + console.log('[UpdateGameFiles] PWR file deleted from cache after successful update:', pwrFile); + } + } catch (delErr) { + console.warn('[UpdateGameFiles] Failed to delete PWR file from cache:', delErr.message); + } + + if (progressCallback) { + progressCallback('Replacing game files...', 80, null, null, null); + } + + if (fs.existsSync(gameDir)) { + console.log('Removing old game files...'); + let retries = 3; + while (retries > 0) { + try { + fs.rmSync(gameDir, { recursive: true, force: true }); + break; + } catch (err) { + if ((err.code === 'EPERM' || err.code === 'EBUSY') && retries > 0) { + retries--; + console.log(`[UpdateGameFiles] Removal failed with ${err.code}, retrying in 1s... (${retries} retries left)`); + await new Promise(resolve => setTimeout(resolve, 1000)); + } else { + throw err; + } } } } - } - fs.renameSync(tempUpdateDir, gameDir); + fs.renameSync(tempUpdateDir, gameDir); + } const homeUIResult = await downloadAndReplaceHomePageUI(gameDir, progressCallback); console.log('HomePage.ui update result after update:', homeUIResult); @@ -833,36 +890,23 @@ function validateGameDirectory(gameDir, stagingDir) { } // Enhanced PWR file validation +// Accepts intermediate patches (50+ MB) and full installs (1.5+ GB) function validatePWRFile(filePath) { try { if (!fs.existsSync(filePath)) { return false; } - + const stats = fs.statSync(filePath); const sizeInMB = stats.size / 1024 / 1024; - + + // PWR files should be at least 1 MB if (stats.size < 1024 * 1024) { + console.log(`[PWR Validation] File too small: ${sizeInMB.toFixed(2)} MB`); 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}`); - + + console.log(`[PWR Validation] File size: ${sizeInMB.toFixed(2)} MB - OK`); return true; } catch (error) { console.error(`[PWR Validation] Error:`, error.message); diff --git a/backend/services/versionManager.js b/backend/services/versionManager.js index 1b42b9b..e846add 100644 --- a/backend/services/versionManager.js +++ b/backend/services/versionManager.js @@ -2,186 +2,240 @@ 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'; -const NEW_API_URL = 'https://thecute.cloud/ShipOfYarn/api.php'; +// Patches CDN via auth server redirect gateway (allows instant CDN switching) +const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws'; +const MIRROR_BASE_URL = `https://${AUTH_DOMAIN}/patches`; +const MIRROR_MANIFEST_URL = `${MIRROR_BASE_URL}/manifest.json`; -let apiCache = null; -let apiCacheTime = 0; -const API_CACHE_DURATION = 60000; // 1 minute +// Fallback: latest known build number if manifest is unreachable +const FALLBACK_LATEST_BUILD = 11; -async function fetchNewAPI() { +let manifestCache = null; +let manifestCacheTime = 0; +const MANIFEST_CACHE_DURATION = 60000; // 1 minute + +/** + * Fetch the mirror manifest from MEGA S4 + */ +async function fetchMirrorManifest() { const now = Date.now(); - - if (apiCache && (now - apiCacheTime) < API_CACHE_DURATION) { - console.log('[NewAPI] Using cached API data'); - return apiCache; + + if (manifestCache && (now - manifestCacheTime) < MANIFEST_CACHE_DURATION) { + console.log('[Mirror] Using cached manifest'); + return manifestCache; } - + try { - console.log('[NewAPI] Fetching from:', NEW_API_URL); - const response = await axios.get(NEW_API_URL, { + console.log('[Mirror] Fetching manifest from:', MIRROR_MANIFEST_URL); + const response = await axios.get(MIRROR_MANIFEST_URL, { timeout: 15000, - headers: { - 'User-Agent': 'Hytale-F2P-Launcher' - } + headers: { 'User-Agent': 'Hytale-F2P-Launcher' } }); - - if (response.data && response.data.hytale) { - apiCache = response.data; - apiCacheTime = now; - console.log('[NewAPI] API data fetched and cached successfully'); + + if (response.data && response.data.files) { + manifestCache = response.data; + manifestCacheTime = now; + console.log('[Mirror] Manifest fetched successfully'); return response.data; - } else { - throw new Error('Invalid API response structure'); } + throw new Error('Invalid manifest structure'); } catch (error) { - console.error('[NewAPI] Error fetching API:', error.message); - if (apiCache) { - console.log('[NewAPI] Using expired cache due to error'); - return apiCache; + console.error('[Mirror] Error fetching manifest:', error.message); + if (manifestCache) { + console.log('[Mirror] Using expired cache'); + return manifestCache; } throw error; } } -async function getLatestVersionFromNewAPI(branch = 'release') { - try { - const apiData = await fetchNewAPI(); - const osName = getOS(); - const arch = getArch(); - - let osKey = osName; - if (osName === 'darwin') { - osKey = 'mac'; +/** + * Parse manifest to get available patches for current platform + * Returns array of { from, to, key, size } + */ +function getPlatformPatches(manifest, branch = 'release') { + const os = getOS(); + const arch = getArch(); + const prefix = `${os}/${arch}/${branch}/`; + const patches = []; + + for (const [key, info] of Object.entries(manifest.files)) { + if (key.startsWith(prefix) && key.endsWith('.pwr')) { + const filename = key.slice(prefix.length, -4); // e.g., "0_to_11" + const match = filename.match(/^(\d+)_to_(\d+)$/); + if (match) { + patches.push({ + from: parseInt(match[1]), + to: parseInt(match[2]), + key, + size: info.size + }); + } } - - const branchData = apiData.hytale[branch]; - if (!branchData || !branchData[osKey]) { - throw new Error(`No data found for branch: ${branch}, OS: ${osKey}`); - } - - const osData = branchData[osKey]; - - const versions = Object.keys(osData).filter(key => key.endsWith('.pwr')); - - if (versions.length === 0) { - throw new Error(`No .pwr files found for ${osKey}`); - } - - const versionNumbers = versions.map(v => { - const match = v.match(/v(\d+)/); - return match ? parseInt(match[1]) : 0; - }); - - const latestVersionNumber = Math.max(...versionNumbers); - console.log(`[NewAPI] Latest version number: ${latestVersionNumber} for branch ${branch}`); - - return `v${latestVersionNumber}`; - } catch (error) { - console.error('[NewAPI] Error getting latest version:', error.message); - throw error; } + + return patches; } -async function getPWRUrlFromNewAPI(branch = 'release', version = 'v8') { - try { - const apiData = await fetchNewAPI(); - const osName = getOS(); - const arch = getArch(); - - let osKey = osName; - if (osName === 'darwin') { - osKey = 'mac'; - } - - let fileName; - if (osName === 'windows') { - fileName = `${version}-windows-amd64.pwr`; - } else if (osName === 'linux') { - fileName = `${version}-linux-amd64.pwr`; - } else if (osName === 'darwin') { - fileName = `${version}-darwin-arm64.pwr`; - } - - const branchData = apiData.hytale[branch]; - if (!branchData || !branchData[osKey]) { - throw new Error(`No data found for branch: ${branch}, OS: ${osKey}`); - } - - const osData = branchData[osKey]; - const url = osData[fileName]; - - if (!url) { - throw new Error(`No URL found for ${fileName}`); - } - - console.log(`[NewAPI] URL for ${fileName}: ${url}`); - return url; - } catch (error) { - console.error('[NewAPI] Error getting PWR URL:', error.message); - throw error; +/** + * Find optimal patch path using BFS with download size minimization + * Returns array of { from, to, url, size, key } steps, or null if no path found + */ +function findOptimalPatchPath(currentBuild, targetBuild, patches) { + if (currentBuild >= targetBuild) return []; + + const edges = {}; + for (const patch of patches) { + if (!edges[patch.from]) edges[patch.from] = []; + edges[patch.from].push(patch); } + + const queue = [{ build: currentBuild, path: [], totalSize: 0 }]; + let bestPath = null; + let bestSize = Infinity; + + while (queue.length > 0) { + const { build, path, totalSize } = queue.shift(); + + if (build === targetBuild) { + if (totalSize < bestSize) { + bestPath = path; + bestSize = totalSize; + } + continue; + } + + if (totalSize >= bestSize) continue; + + const nextEdges = edges[build] || []; + for (const edge of nextEdges) { + if (edge.to <= build || edge.to > targetBuild) continue; + if (path.some(p => p.to === edge.to)) continue; + + queue.push({ + build: edge.to, + path: [...path, { + from: edge.from, + to: edge.to, + url: `${MIRROR_BASE_URL}/${edge.key}`, + size: edge.size, + key: edge.key + }], + totalSize: totalSize + edge.size + }); + } + } + + return bestPath; +} + +/** + * Get the optimal update plan from currentBuild to targetBuild + * Returns { steps: [{from, to, url, size}], totalSize, isFullInstall } + */ +async function getUpdatePlan(currentBuild, targetBuild, branch = 'release') { + const manifest = await fetchMirrorManifest(); + const patches = getPlatformPatches(manifest, branch); + + // Try optimal path + const steps = findOptimalPatchPath(currentBuild, targetBuild, patches); + + if (steps && steps.length > 0) { + const totalSize = steps.reduce((sum, s) => sum + s.size, 0); + console.log(`[Mirror] Update plan: ${steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')} (${(totalSize / 1024 / 1024).toFixed(0)} MB)`); + return { steps, totalSize, isFullInstall: steps.length === 1 && steps[0].from === 0 }; + } + + // Fallback: full install 0 -> target + const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild); + if (fullPatch) { + const step = { + from: 0, + to: targetBuild, + url: `${MIRROR_BASE_URL}/${fullPatch.key}`, + size: fullPatch.size, + key: fullPatch.key + }; + console.log(`[Mirror] Full install: 0\u2192${targetBuild} (${(fullPatch.size / 1024 / 1024).toFixed(0)} MB)`); + return { steps: [step], totalSize: fullPatch.size, isFullInstall: true }; + } + + throw new Error(`No patch path found from build ${currentBuild} to ${targetBuild} for ${getOS()}/${getArch()}`); } async function getLatestClientVersion(branch = 'release') { try { - console.log(`[NewAPI] Fetching latest client version from new API (branch: ${branch})...`); - - // Utiliser la nouvelle API - const latestVersion = await getLatestVersionFromNewAPI(branch); - console.log(`[NewAPI] Latest client version for ${branch}: ${latestVersion}`); - return latestVersion; - - } catch (error) { - console.error('[NewAPI] Error fetching client version from new API:', error.message); - console.log('[NewAPI] Falling back to old API...'); - - // Fallback vers l'ancienne API si la nouvelle échoue - try { - const response = await smartRequest(`https://files.hytalef2p.com/api/version_client?branch=${branch}`, { - timeout: 40000, - headers: { - 'User-Agent': 'Hytale-F2P-Launcher' - } - }); + console.log(`[Mirror] Fetching latest client version (branch: ${branch})...`); + const manifest = await fetchMirrorManifest(); + const patches = getPlatformPatches(manifest, branch); - if (response.data && response.data.client_version) { - const version = response.data.client_version; - console.log(`Latest client version for ${branch} (old API): ${version}`); - return version; - } else { - console.log('Warning: Invalid API response, falling back to latest known version (v8)'); - return 'v8'; - } - } catch (fallbackError) { - console.error('Error fetching client version from old API:', fallbackError.message); - console.log('Warning: Both APIs unavailable, falling back to latest known version (v8)'); - return 'v8'; + if (patches.length === 0) { + console.log(`[Mirror] No patches for branch '${branch}', using fallback`); + return `v${FALLBACK_LATEST_BUILD}`; } + + const latestBuild = Math.max(...patches.map(p => p.to)); + console.log(`[Mirror] Latest client version: v${latestBuild}`); + return `v${latestBuild}`; + } catch (error) { + console.error('[Mirror] Error:', error.message); + return `v${FALLBACK_LATEST_BUILD}`; } } -// Fonction utilitaire pour extraire le numéro de version -// Supporte les formats: "7.pwr", "v8", "v8-windows-amd64.pwr", etc. +/** + * Get PWR download URL for fresh install (0 -> target) + * Backward-compatible with old getPWRUrlFromNewAPI signature + * Checks mirror first, then constructs URL for the branch + */ +async function getPWRUrl(branch = 'release', version = 'v11') { + const targetBuild = extractVersionNumber(version); + const os = getOS(); + const arch = getArch(); + + try { + const manifest = await fetchMirrorManifest(); + const patches = getPlatformPatches(manifest, branch); + const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild); + + if (fullPatch) { + const url = `${MIRROR_BASE_URL}/${fullPatch.key}`; + console.log(`[Mirror] PWR URL: ${url}`); + return url; + } + + if (patches.length > 0) { + // Branch exists in mirror but no full patch for this target - construct URL + console.log(`[Mirror] No 0->${targetBuild} patch found, constructing URL`); + } else { + console.log(`[Mirror] Branch '${branch}' not in mirror, constructing URL`); + } + } catch (error) { + console.error('[Mirror] Error getting PWR URL:', error.message); + } + + // Construct mirror URL (will work if patch was uploaded but manifest is stale) + return `${MIRROR_BASE_URL}/${os}/${arch}/${branch}/0_to_${targetBuild}.pwr`; +} + +// Backward-compatible alias +const getPWRUrlFromNewAPI = getPWRUrl; + +// Utility function to extract version number +// Supports: "7.pwr", "v8", "v8-windows-amd64.pwr", "5_to_10", etc. function extractVersionNumber(version) { if (!version) return 0; - - // Nouveau format: "v8" ou "v8-xxx.pwr" + + // New format: "v8" or "v8-xxx.pwr" const vMatch = version.match(/v(\d+)/); - if (vMatch) { - return parseInt(vMatch[1]); - } - - // Ancien format: "7.pwr" + if (vMatch) return parseInt(vMatch[1]); + + // Old format: "7.pwr" const pwrMatch = version.match(/(\d+)\.pwr/); - if (pwrMatch) { - return parseInt(pwrMatch[1]); - } - - // Fallback: essayer de parser directement + if (pwrMatch) return parseInt(pwrMatch[1]); + + // Fallback const num = parseInt(version); return isNaN(num) ? 0 : num; } @@ -189,7 +243,7 @@ function extractVersionNumber(version) { function buildArchiveUrl(buildNumber, branch = 'release') { const os = getOS(); const arch = getArch(); - return `${BASE_PATCH_URL}/${os}/${arch}/${branch}/0/${buildNumber}.pwr`; + return `${MIRROR_BASE_URL}/${os}/${arch}/${branch}/0_to_${buildNumber}.pwr`; } async function checkArchiveExists(buildNumber, branch = 'release') { @@ -197,91 +251,56 @@ async function checkArchiveExists(buildNumber, branch = 'release') { try { const response = await axios.head(url, { timeout: 10000 }); return response.status === 200; - } catch (error) { + } catch { return false; } } -async function discoverAvailableVersions(latestKnown, branch = 'release', maxProbe = 50) { - const available = []; - const latest = extractVersionNumber(latestKnown); - - for (let i = latest; i >= Math.max(1, latest - maxProbe); i--) { - const exists = await checkArchiveExists(i, branch); - if (exists) { - available.push(`${i}.pwr`); - } - } - - return available; -} - -async function fetchPatchManifest(branch = 'release') { +async function discoverAvailableVersions(latestKnown, branch = 'release') { try { - const os = getOS(); - const arch = getArch(); - const response = await smartRequest(`${MANIFEST_API}?branch=${branch}&os=${os}&arch=${arch}`, { - timeout: 10000 - }); - return response.data.patches || {}; - } catch (error) { - console.error('Failed to fetch patch manifest:', error.message); - return {}; + const manifest = await fetchMirrorManifest(); + const patches = getPlatformPatches(manifest, branch); + const versions = [...new Set(patches.map(p => p.to))].sort((a, b) => b - a); + return versions.map(v => `${v}.pwr`); + } catch { + return []; } } async function extractVersionDetails(targetVersion, branch = 'release') { const buildNumber = extractVersionNumber(targetVersion); - const previousBuild = buildNumber - 1; - - const manifest = await fetchPatchManifest(branch); - const patchInfo = manifest[buildNumber]; - + const fullUrl = buildArchiveUrl(buildNumber, branch); + return { version: targetVersion, - buildNumber: buildNumber, + buildNumber, buildName: `HYTALE-Build-${buildNumber}`, - fullUrl: patchInfo?.original_url || buildArchiveUrl(buildNumber, branch), - differentialUrl: patchInfo?.patch_url || null, - checksum: patchInfo?.patch_hash || null, - sourceVersion: patchInfo?.from ? `${patchInfo.from}.pwr` : (previousBuild > 0 ? `${previousBuild}.pwr` : null), - isDifferential: !!patchInfo?.proper_patch, - releaseNotes: patchInfo?.patch_note || null + fullUrl, + differentialUrl: null, + checksum: null, + sourceVersion: null, + isDifferential: false, + releaseNotes: null }; } -function canUseDifferentialUpdate(currentVersion, targetDetails) { - if (!targetDetails) return false; - if (!targetDetails.differentialUrl) return false; - if (!targetDetails.isDifferential) return false; - - if (!currentVersion) return false; - - const currentBuild = extractVersionNumber(currentVersion); - const expectedSource = extractVersionNumber(targetDetails.sourceVersion); - - return currentBuild === expectedSource; +function canUseDifferentialUpdate() { + // Differential updates are now handled via getUpdatePlan() + return false; } function needsIntermediatePatches(currentVersion, targetVersion) { if (!currentVersion) return []; - const current = extractVersionNumber(currentVersion); const target = extractVersionNumber(targetVersion); - - const intermediates = []; - for (let i = current + 1; i <= target; i++) { - intermediates.push(`${i}.pwr`); - } - - return intermediates; + if (current >= target) return []; + return [targetVersion]; } async function computeFileChecksum(filePath) { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256'); const stream = fs.createReadStream(filePath); - stream.on('data', data => hash.update(data)); stream.on('end', () => resolve(hash.digest('hex'))); stream.on('error', reject); @@ -290,7 +309,6 @@ async function computeFileChecksum(filePath) { async function validateChecksum(filePath, expectedChecksum) { if (!expectedChecksum) return true; - const actualChecksum = await computeFileChecksum(filePath); return actualChecksum === expectedChecksum; } @@ -299,7 +317,7 @@ function getInstalledClientVersion() { try { const { loadVersionClient } = require('../core/config'); return loadVersionClient(); - } catch (err) { + } catch { return null; } } @@ -315,8 +333,12 @@ module.exports = { computeFileChecksum, validateChecksum, getInstalledClientVersion, - fetchNewAPI, - getLatestVersionFromNewAPI, + fetchMirrorManifest, + getPWRUrl, getPWRUrlFromNewAPI, - extractVersionNumber + getUpdatePlan, + extractVersionNumber, + getPlatformPatches, + findOptimalPatchPath, + MIRROR_BASE_URL }; diff --git a/docs/PATCH_CDN_INFRASTRUCTURE.md b/docs/PATCH_CDN_INFRASTRUCTURE.md new file mode 100644 index 0000000..15b01e6 --- /dev/null +++ b/docs/PATCH_CDN_INFRASTRUCTURE.md @@ -0,0 +1,217 @@ +# Patch CDN Infrastructure & Game Update System + +## Overview + +The F2P launcher downloads game patches through a CDN redirect gateway hosted on the auth server. This allows instant CDN switching (e.g., for DMCA takedowns) without releasing a new launcher version. + +## Architecture + +``` +Launcher --> GET auth.sanasol.ws/patches/manifest.json + --> 302 --> mega.io/.../manifest.json + +Launcher --> GET auth.sanasol.ws/patches/windows/amd64/release/0_to_11.pwr + --> 302 --> mega.io/.../windows/amd64/release/0_to_11.pwr +``` + +The auth server acts as a pure redirect gateway (302). No bandwidth is consumed on the auth server - all actual file transfers happen directly between the launcher and the CDN. + +## URLs + +| URL | Purpose | +|-----|---------| +| `https://auth.sanasol.ws/patches/*` | Redirect gateway (302 -> CDN) | +| `https://auth.sanasol.ws/patches/manifest.json` | Patch manifest (redirects to CDN) | +| `https://auth.sanasol.ws/admin/page/settings` | Admin panel to change CDN URL | +| `https://auth.sanasol.ws/admin/api/settings/patches-cdn` | API to GET/POST CDN base URL | + +### Default CDN (MEGA S4) + +``` +Base URL: https://s3.g.s4.mega.io/kcvismkrtfcalgwxzsazbq46l72dwsypqaham/hytale/patches +``` + +### Changing CDN (DMCA response) + +1. Go to `https://auth.sanasol.ws/admin/page/settings` +2. Find "Patches CDN Base URL" section +3. Change URL to new CDN (e.g., `https://new-cdn.example.com/patches`) +4. Click "Save" - all launcher requests instantly redirect to new CDN +5. No launcher update needed + +## Manifest Format + +The manifest is a JSON file listing all available patch files: + +```json +{ + "updated": "2026-02-20T13:20:09.776Z", + "files": { + "windows/amd64/release/0_to_11.pwr": { "size": 1618804736 }, + "windows/amd64/release/10_to_11.pwr": { "size": 62914560 }, + "darwin/arm64/release/0_to_11.pwr": { "size": 1617100800 }, + "server/release": { "version": "2026.02.19-1a311a592", "size": 1509949440, "sha256": "..." }, + ... + } +} +``` + +### Key Structure + +File keys follow the pattern: `{os}/{arch}/{branch}/{from}_to_{to}.pwr` + +- **OS**: `windows`, `linux`, `darwin` +- **Arch**: `amd64`, `arm64` +- **Branch**: `release`, `pre-release` +- **Patch**: `{from}_to_{to}.pwr` (e.g., `0_to_11.pwr` for full install, `10_to_11.pwr` for differential) + +Server builds use: `server/{branch}` with `version`, `size`, `sha256` fields. + +## Game Update Process + +### 1. Version Check + +``` +Launcher calls: getLatestClientVersion(branch) + -> Fetches manifest from auth.sanasol.ws/patches/manifest.json + -> Finds highest build number for current platform/branch + -> Returns "v{buildNumber}" (e.g., "v11") +``` + +### 2. Update Plan (Optimal Patch Routing) + +``` +Launcher calls: getUpdatePlan(currentBuild, targetBuild, branch) + -> Fetches manifest + -> Finds available patches for platform + -> Uses BFS to find optimal path (minimizes total download size) + -> Example: build 5 -> 11 might use: 5->10 (148MB) + 10->11 (60MB) + instead of: 0->11 (1.5GB) +``` + +### 3. Download & Apply + +``` +For each step in the update plan: + 1. Download .pwr file from auth.sanasol.ws/patches/{key} + (redirects to CDN, supports resume via Range headers) + 2. Apply patch using butler tool: + butler apply --staging-dir + 3. Save version after each step +``` + +### 4. Fresh Install + +For first-time installs (currentBuild = 0): +- Downloads `0_to_{target}.pwr` (full install, ~1.5GB) +- Applies with butler to create the full game directory + +### 5. Differential Update + +For existing installations: +- Finds optimal patch chain (e.g., `10_to_11.pwr` at ~60MB) +- Applies incrementally, saving progress after each step +- Falls back to full install if no patch path found + +## Mirror Sync Script + +The mirror script (`scripts/hytale-mirror.js`) downloads patches from the official Hytale API and uploads to MEGA S4. + +### Usage + +```bash +cd scripts +node hytale-mirror.js download # Download patches locally +node hytale-mirror.js upload # Upload to MEGA S4 via rclone +node hytale-mirror.js sync # Download + Upload in one step +``` + +### What It Does + +1. **Discovery**: Calls Hytale API to find available patches for all platforms +2. **Download**: Downloads .pwr files to `scripts/mirror/` directory +3. **Manifest Generation**: Creates `manifest.json` with file sizes (no local paths) +4. **Upload**: Uses `rclone` to sync to MEGA S4 + +### SOCKS5 Proxy + +- API discovery calls use SOCKS5 proxy rotation (for rate limiting) +- File downloads do NOT use proxy (too slow for large files) +- Proxy list in `proxies.json` (auto-refreshed from proxy service) + +### Prerequisites + +- `rclone` configured with `megas4` remote pointing to MEGA S4 +- Node.js 20+ +- Network access to Hytale API endpoints + +## Launcher Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `HYTALE_AUTH_DOMAIN` | `auth.sanasol.ws` | Auth domain (used for patch redirects) | + +### Key Files + +| File | Description | +|------|-------------| +| `backend/services/versionManager.js` | Manifest fetching, version checking, update planning | +| `backend/managers/differentialUpdateManager.js` | Download orchestration, butler integration | +| `backend/utils/fileManager.js` | File download with retry, resume, stall detection | +| `backend/managers/gameLauncher.js` | Game launch with token fetch, patching, signing | + +### Constants (versionManager.js) + +```javascript +const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws'; +const MIRROR_BASE_URL = `https://${AUTH_DOMAIN}/patches`; +const MIRROR_MANIFEST_URL = `${MIRROR_BASE_URL}/manifest.json`; +const MANIFEST_CACHE_DURATION = 60000; // 1 minute cache +const FALLBACK_LATEST_BUILD = 11; // If manifest unreachable +``` + +## Auth Server Implementation + +### Routes + +``` +GET /patches/* -> handlePatchRedirect() + - Extracts path after /patches/ + - Reads CDN base URL from Redis settings + - Returns 302 redirect to {baseUrl}/{path} + - Tracks download metrics + +GET /admin/api/settings/patches-cdn -> getPatchesCdnBaseUrl() +POST /admin/api/settings/patches-cdn -> setPatchesCdnBaseUrl() +``` + +### Redis Storage + +``` +settings:global -> { patchesCdnBaseUrl: "https://s3.g.s4.mega.io/..." } +metrics:downloads -> { "patch:manifest.json": count, ... } +``` + +## Troubleshooting + +### "Invalid manifest structure" error +- Check manifest.json is valid JSON with `files` object +- Verify CDN is accessible: `curl -sL https://auth.sanasol.ws/patches/manifest.json | python3 -m json.tool` +- Check admin settings for correct CDN URL + +### 0-byte downloads +- Verify redirect works: `curl -sI https://auth.sanasol.ws/patches/darwin/arm64/release/0_to_11.pwr` +- Should show `302` with `Location` header +- Test actual download: `curl -sL -o /dev/null -w "%{size_download}" -r 0-1023 ` + +### Manifest has local paths +- Regenerate manifest: `node scripts/hytale-mirror.js download` (re-scans files) +- Re-upload: `node scripts/hytale-mirror.js upload` +- Verify: entries should only have `{ size: }`, no `path` field + +### CDN switch not taking effect +- Check Redis: CDN URL stored in `settings:global` +- Verify via API: `curl https://auth.sanasol.ws/admin/api/settings/patches-cdn` +- Manifest is cached for 1 minute in launcher - wait or restart diff --git a/package.json b/package.json index 6b94efa..7da50bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hytale-f2p-launcher", - "version": "2.3.0", + "version": "2.3.1", "description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support", "homepage": "https://github.com/amiayweb/Hytale-F2P", "main": "main.js",