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 <noreply@anthropic.com>
This commit is contained in:
sanasol
2026-02-20 14:36:09 +01:00
parent 66112f15b2
commit 0dafb17c7b
6 changed files with 685 additions and 367 deletions

View File

@@ -3,7 +3,7 @@ const path = require('path');
const { execFile } = require('child_process'); const { execFile } = require('child_process');
const { downloadFile, retryDownload } = require('../utils/fileManager'); const { downloadFile, retryDownload } = require('../utils/fileManager');
const { getOS, getArch } = require('../utils/platformUtils'); 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 { installButler } = require('./butlerManager');
const { GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths'); const { GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
const { saveVersionClient } = require('../core/config'); const { saveVersionClient } = require('../core/config');
@@ -156,15 +156,15 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr
console.log(`Initiating intelligent update to version ${targetVersion}`); console.log(`Initiating intelligent update to version ${targetVersion}`);
const currentVersion = getInstalledClientVersion(); const currentVersion = getInstalledClientVersion();
console.log(`Current version: ${currentVersion || 'none (clean install)'}`); const currentBuild = extractVersionNumber(currentVersion) || 0;
console.log(`Target version: ${targetVersion}`); const targetBuild = extractVersionNumber(targetVersion);
console.log(`Branch: ${branch}`); console.log(`Current build: ${currentBuild}, Target build: ${targetBuild}, Branch: ${branch}`);
// For non-release branches, always do full install
if (branch !== 'release') { 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 versionDetails = await extractVersionDetails(targetVersion, branch);
const archiveName = path.basename(versionDetails.fullUrl); const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`);
const archivePath = path.join(cacheDir, `${branch}_${archiveName}`);
if (progressCallback) { if (progressCallback) {
progressCallback('Downloading full game archive (pre-release)...', 0, null, null, null); progressCallback('Downloading full game archive (pre-release)...', 0, null, null, null);
@@ -177,14 +177,14 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr
return; return;
} }
if (!currentVersion) { // Clean install (no current version)
if (currentBuild === 0) {
console.log('No existing installation detected - downloading full archive'); console.log('No existing installation detected - downloading full archive');
const versionDetails = await extractVersionDetails(targetVersion, branch); const versionDetails = await extractVersionDetails(targetVersion, branch);
const archiveName = path.basename(versionDetails.fullUrl); const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`);
const archivePath = path.join(cacheDir, `${branch}_${archiveName}`);
if (progressCallback) { 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 acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback);
@@ -194,59 +194,67 @@ async function performIntelligentUpdate(targetVersion, branch = 'release', progr
return; return;
} }
const patchesToApply = needsIntermediatePatches(currentVersion, targetVersion); // Already at target
if (currentBuild >= targetBuild) {
if (patchesToApply.length === 0) { console.log('Already at target version or newer');
console.log('Already at target version or invalid version sequence');
return; 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);
for (let i = 0; i < patchesToApply.length; i++) { console.log(`Applying ${plan.steps.length} patch(es): ${plan.steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')}`);
const patchVersion = patchesToApply[i];
const versionDetails = await extractVersionDetails(patchVersion, branch);
const canDifferential = canUseDifferentialUpdate(getInstalledClientVersion(), versionDetails); for (let i = 0; i < plan.steps.length; i++) {
const step = plan.steps[i];
if (!canDifferential || !versionDetails.differentialUrl) { const stepName = `${step.from}_to_${step.to}`;
console.log(`WARNING: Differential patch not available for ${patchVersion}, using full archive`); const archivePath = path.join(cacheDir, `${branch}_${stepName}.pwr`);
const archiveName = path.basename(versionDetails.fullUrl); const isDifferential = step.from !== 0;
const archivePath = path.join(cacheDir, `${branch}_${archiveName}`);
if (progressCallback) { 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(step.url, archivePath, null, progressCallback);
if (progressCallback) {
progressCallback(`Applying patch ${i + 1}/${plan.steps.length}: ${stepName}...`, 50, null, null, null);
}
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, isDifferential);
// Clean up patch file
if (fs.existsSync(archivePath)) {
try {
fs.unlinkSync(archivePath);
console.log(`Cleaned up: ${stepName}.pwr`);
} catch (cleanupErr) {
console.warn(`Failed to cleanup: ${cleanupErr.message}`);
}
}
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 acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback);
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false); await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false);
} else { saveVersionClient(targetVersion);
console.log(`Applying differential patch: ${versionDetails.sourceVersion} -> ${patchVersion}`);
const archiveName = path.basename(versionDetails.differentialUrl);
const archivePath = path.join(cacheDir, `${branch}_patch_${archiveName}`);
if (progressCallback) {
progressCallback(`Applying patch ${i + 1}/${patchesToApply.length}: ${patchVersion}...`, 0, null, null, null);
} }
await acquireGameArchive(versionDetails.differentialUrl, archivePath, versionDetails.checksum, progressCallback);
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, true);
if (fs.existsSync(archivePath)) {
try {
fs.unlinkSync(archivePath);
console.log(`Cleaned up patch file: ${archiveName}`);
} catch (cleanupErr) {
console.warn(`Failed to cleanup patch file: ${cleanupErr.message}`);
}
}
}
saveVersionClient(patchVersion);
console.log(`Patch ${patchVersion} applied successfully (${i + 1}/${patchesToApply.length})`);
}
console.log(`Update completed successfully. Version ${targetVersion} is now installed.`);
} }
async function ensureGameInstalled(targetVersion, branch = 'release', progressCallback, gameDir = GAME_DIR, cacheDir = CACHE_DIR, toolsDir = TOOLS_DIR) { async function ensureGameInstalled(targetVersion, branch = 'release', progressCallback, gameDir = GAME_DIR, cacheDir = CACHE_DIR, toolsDir = TOOLS_DIR) {

View File

@@ -61,12 +61,39 @@ async function fetchAuthTokens(uuid, name) {
} }
const data = await response.json(); const data = await response.json();
console.log('Auth tokens received from server'); const identityToken = data.IdentityToken || data.identityToken;
const 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 { return {
identityToken: data.IdentityToken || data.identityToken, identityToken: retryData.IdentityToken || retryData.identityToken,
sessionToken: data.SessionToken || data.sessionToken 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) { } catch (error) {
console.error('Failed to fetch auth tokens:', error.message); console.error('Failed to fetch auth tokens:', error.message);
// Fallback to local generation if server unavailable // Fallback to local generation if server unavailable

View File

@@ -5,7 +5,7 @@ const { promisify } = require('util');
const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursive, GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths'); const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursive, GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
const { getOS, getArch } = require('../utils/platformUtils'); const { getOS, getArch } = require('../utils/platformUtils');
const { downloadFile, retryDownload, retryStalledDownload, MAX_AUTOMATIC_STALL_RETRIES } = require('../utils/fileManager'); 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 { FORCE_CLEAN_INSTALL_VERSION, CLEAN_INSTALL_TEST_VERSION } = require('../core/testConfig');
const { installButler } = require('./butlerManager'); const { installButler } = require('./butlerManager');
const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFileManager'); 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 osName = getOS();
const arch = getArch(); 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.'); 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 url;
let isUsingNewAPI = false;
if (directUrl) {
url = directUrl;
console.log(`[DownloadPWR] Using direct URL: ${url}`);
} else {
const { getPWRUrl } = require('../services/versionManager');
try { try {
console.log(`[DownloadPWR] Fetching URL from new API for branch: ${branch}, version: ${fileName}`); console.log(`[DownloadPWR] Fetching mirror URL for branch: ${branch}, version: ${fileName}`);
url = await getPWRUrlFromNewAPI(branch, fileName); url = await getPWRUrl(branch, fileName);
isUsingNewAPI = true; console.log(`[DownloadPWR] Mirror URL: ${url}`);
console.log(`[DownloadPWR] Using new API URL: ${url}`);
} catch (error) { } catch (error) {
console.error(`[DownloadPWR] Failed to get URL from new API: ${error.message}`); console.error(`[DownloadPWR] Failed to get mirror URL: ${error.message}`);
console.log(`[DownloadPWR] Falling back to old URL format`); const { MIRROR_BASE_URL } = require('../services/versionManager');
url = `https://game-patches.hytale.com/patches/${osName}/${arch}/${branch}/0/${fileName}.pwr`; 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`); const dest = path.join(cacheDir, `${branch}_${fileName}.pwr`);
// Check if file exists and validate it // Check if file exists and validate it
if (fs.existsSync(dest) && !manualRetry) { 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); const stats = fs.statSync(dest);
if (stats.size < 1024 * 1024) { if (stats.size > 1024 * 1024) {
return false; 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`);
} }
// Check if file is under 1.5 GB (incomplete download) console.log(`[DownloadPWR] Downloading from: ${url}`);
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 from ${isUsingNewAPI ? 'NEW API' : 'old API'}:`, url);
try { try {
if (manualRetry) { if (manualRetry) {
@@ -203,7 +198,7 @@ async function retryPWRDownload(branch, fileName, progressCallback, cacheDir = C
return await downloadPWR(branch, fileName, progressCallback, cacheDir, true); 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] Starting PWR application with:`);
console.log(`[Butler] - PWR file: ${pwrFile}`); console.log(`[Butler] - PWR file: ${pwrFile}`);
console.log(`[Butler] - Staging dir: ${path.join(gameDir, 'staging-temp')}`); console.log(`[Butler] - Staging dir: ${path.join(gameDir, 'staging-temp')}`);
@@ -227,12 +222,13 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
const gameLatest = gameDir; const gameLatest = gameDir;
const stagingDir = path.join(gameLatest, 'staging-temp'); const stagingDir = path.join(gameLatest, 'staging-temp');
if (!skipExistingCheck) {
const clientPath = findClientPath(gameLatest); const clientPath = findClientPath(gameLatest);
if (clientPath) { if (clientPath) {
console.log('Game files detected, skipping patch installation.'); console.log('Game files detected, skipping patch installation.');
return; return;
} }
}
// Validate and prepare directories // Validate and prepare directories
validateGameDirectory(gameLatest, stagingDir); validateGameDirectory(gameLatest, stagingDir);
@@ -412,6 +408,65 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
} }
console.log(`Updating game files to version: ${newVersion} (branch: ${branch})`); console.log(`Updating game files to version: ${newVersion} (branch: ${branch})`);
// Determine update strategy: intermediate patches vs full reinstall
const currentVersion = loadVersionClient();
const currentBuild = extractVersionNumber(currentVersion) || 0;
const targetBuild = extractVersionNumber(newVersion);
let useIntermediatePatches = false;
let updatePlan = null;
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);
}
}
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 {
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'); tempUpdateDir = path.join(gameDir, '..', 'temp_update');
if (fs.existsSync(tempUpdateDir)) { if (fs.existsSync(tempUpdateDir)) {
@@ -430,7 +485,7 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
} }
await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir, branch, cacheDir); await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir, branch, cacheDir);
// Delete PWR file from cache after successful update
try { try {
if (fs.existsSync(pwrFile)) { if (fs.existsSync(pwrFile)) {
fs.unlinkSync(pwrFile); fs.unlinkSync(pwrFile);
@@ -439,6 +494,7 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
} catch (delErr) { } catch (delErr) {
console.warn('[UpdateGameFiles] Failed to delete PWR file from cache:', delErr.message); console.warn('[UpdateGameFiles] Failed to delete PWR file from cache:', delErr.message);
} }
if (progressCallback) { if (progressCallback) {
progressCallback('Replacing game files...', 80, null, null, null); progressCallback('Replacing game files...', 80, null, null, null);
} }
@@ -463,6 +519,7 @@ async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR,
} }
fs.renameSync(tempUpdateDir, gameDir); fs.renameSync(tempUpdateDir, gameDir);
}
const homeUIResult = await downloadAndReplaceHomePageUI(gameDir, progressCallback); const homeUIResult = await downloadAndReplaceHomePageUI(gameDir, progressCallback);
console.log('HomePage.ui update result after update:', homeUIResult); console.log('HomePage.ui update result after update:', homeUIResult);
@@ -833,6 +890,7 @@ function validateGameDirectory(gameDir, stagingDir) {
} }
// Enhanced PWR file validation // Enhanced PWR file validation
// Accepts intermediate patches (50+ MB) and full installs (1.5+ GB)
function validatePWRFile(filePath) { function validatePWRFile(filePath) {
try { try {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
@@ -842,27 +900,13 @@ function validatePWRFile(filePath) {
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
const sizeInMB = stats.size / 1024 / 1024; const sizeInMB = stats.size / 1024 / 1024;
// PWR files should be at least 1 MB
if (stats.size < 1024 * 1024) { if (stats.size < 1024 * 1024) {
console.log(`[PWR Validation] File too small: ${sizeInMB.toFixed(2)} MB`);
return false; return false;
} }
// Check if file is under 1.5 GB (incomplete download) console.log(`[PWR Validation] File size: ${sizeInMB.toFixed(2)} MB - OK`);
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; return true;
} catch (error) { } catch (error) {
console.error(`[PWR Validation] Error:`, error.message); console.error(`[PWR Validation] Error:`, error.message);

View File

@@ -2,186 +2,240 @@ const axios = require('axios');
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const { getOS, getArch } = require('../utils/platformUtils'); const { getOS, getArch } = require('../utils/platformUtils');
const { smartRequest } = require('../utils/proxyClient');
const BASE_PATCH_URL = 'https://game-patches.hytale.com/patches'; // Patches CDN via auth server redirect gateway (allows instant CDN switching)
const MANIFEST_API = 'https://files.hytalef2p.com/api/patch_manifest'; const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws';
const NEW_API_URL = 'https://thecute.cloud/ShipOfYarn/api.php'; const MIRROR_BASE_URL = `https://${AUTH_DOMAIN}/patches`;
const MIRROR_MANIFEST_URL = `${MIRROR_BASE_URL}/manifest.json`;
let apiCache = null; // Fallback: latest known build number if manifest is unreachable
let apiCacheTime = 0; const FALLBACK_LATEST_BUILD = 11;
const API_CACHE_DURATION = 60000; // 1 minute
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(); const now = Date.now();
if (apiCache && (now - apiCacheTime) < API_CACHE_DURATION) { if (manifestCache && (now - manifestCacheTime) < MANIFEST_CACHE_DURATION) {
console.log('[NewAPI] Using cached API data'); console.log('[Mirror] Using cached manifest');
return apiCache; return manifestCache;
} }
try { try {
console.log('[NewAPI] Fetching from:', NEW_API_URL); console.log('[Mirror] Fetching manifest from:', MIRROR_MANIFEST_URL);
const response = await axios.get(NEW_API_URL, { const response = await axios.get(MIRROR_MANIFEST_URL, {
timeout: 15000, timeout: 15000,
headers: { headers: { 'User-Agent': 'Hytale-F2P-Launcher' }
'User-Agent': 'Hytale-F2P-Launcher'
}
}); });
if (response.data && response.data.hytale) { if (response.data && response.data.files) {
apiCache = response.data; manifestCache = response.data;
apiCacheTime = now; manifestCacheTime = now;
console.log('[NewAPI] API data fetched and cached successfully'); console.log('[Mirror] Manifest fetched successfully');
return response.data; return response.data;
} else {
throw new Error('Invalid API response structure');
} }
throw new Error('Invalid manifest structure');
} catch (error) { } catch (error) {
console.error('[NewAPI] Error fetching API:', error.message); console.error('[Mirror] Error fetching manifest:', error.message);
if (apiCache) { if (manifestCache) {
console.log('[NewAPI] Using expired cache due to error'); console.log('[Mirror] Using expired cache');
return apiCache; return manifestCache;
} }
throw error; throw error;
} }
} }
async function getLatestVersionFromNewAPI(branch = 'release') { /**
try { * Parse manifest to get available patches for current platform
const apiData = await fetchNewAPI(); * Returns array of { from, to, key, size }
const osName = getOS(); */
function getPlatformPatches(manifest, branch = 'release') {
const os = getOS();
const arch = getArch(); const arch = getArch();
const prefix = `${os}/${arch}/${branch}/`;
const patches = [];
let osKey = osName; for (const [key, info] of Object.entries(manifest.files)) {
if (osName === 'darwin') { if (key.startsWith(prefix) && key.endsWith('.pwr')) {
osKey = 'mac'; const filename = key.slice(prefix.length, -4); // e.g., "0_to_11"
} const match = filename.match(/^(\d+)_to_(\d+)$/);
if (match) {
const branchData = apiData.hytale[branch]; patches.push({
if (!branchData || !branchData[osKey]) { from: parseInt(match[1]),
throw new Error(`No data found for branch: ${branch}, OS: ${osKey}`); to: parseInt(match[2]),
} key,
size: info.size
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 { * Find optimal patch path using BFS with download size minimization
const apiData = await fetchNewAPI(); * Returns array of { from, to, url, size, key } steps, or null if no path found
const osName = getOS(); */
const arch = getArch(); function findOptimalPatchPath(currentBuild, targetBuild, patches) {
if (currentBuild >= targetBuild) return [];
let osKey = osName; const edges = {};
if (osName === 'darwin') { for (const patch of patches) {
osKey = 'mac'; if (!edges[patch.from]) edges[patch.from] = [];
edges[patch.from].push(patch);
} }
let fileName; const queue = [{ build: currentBuild, path: [], totalSize: 0 }];
if (osName === 'windows') { let bestPath = null;
fileName = `${version}-windows-amd64.pwr`; let bestSize = Infinity;
} else if (osName === 'linux') {
fileName = `${version}-linux-amd64.pwr`; while (queue.length > 0) {
} else if (osName === 'darwin') { const { build, path, totalSize } = queue.shift();
fileName = `${version}-darwin-arm64.pwr`;
if (build === targetBuild) {
if (totalSize < bestSize) {
bestPath = path;
bestSize = totalSize;
}
continue;
} }
const branchData = apiData.hytale[branch]; if (totalSize >= bestSize) continue;
if (!branchData || !branchData[osKey]) {
throw new Error(`No data found for branch: ${branch}, OS: ${osKey}`); 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
});
}
} }
const osData = branchData[osKey]; return bestPath;
const url = osData[fileName]; }
if (!url) { /**
throw new Error(`No URL found for ${fileName}`); * 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 };
} }
console.log(`[NewAPI] URL for ${fileName}: ${url}`); // Fallback: full install 0 -> target
return url; const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild);
} catch (error) { if (fullPatch) {
console.error('[NewAPI] Error getting PWR URL:', error.message); const step = {
throw error; 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') { async function getLatestClientVersion(branch = 'release') {
try { try {
console.log(`[NewAPI] Fetching latest client version from new API (branch: ${branch})...`); console.log(`[Mirror] Fetching latest client version (branch: ${branch})...`);
const manifest = await fetchMirrorManifest();
const patches = getPlatformPatches(manifest, branch);
// Utiliser la nouvelle API if (patches.length === 0) {
const latestVersion = await getLatestVersionFromNewAPI(branch); console.log(`[Mirror] No patches for branch '${branch}', using fallback`);
console.log(`[NewAPI] Latest client version for ${branch}: ${latestVersion}`); return `v${FALLBACK_LATEST_BUILD}`;
return latestVersion; }
const latestBuild = Math.max(...patches.map(p => p.to));
console.log(`[Mirror] Latest client version: v${latestBuild}`);
return `v${latestBuild}`;
} catch (error) { } catch (error) {
console.error('[NewAPI] Error fetching client version from new API:', error.message); console.error('[Mirror] Error:', error.message);
console.log('[NewAPI] Falling back to old API...'); return `v${FALLBACK_LATEST_BUILD}`;
// 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'
}
});
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';
}
} }
} }
// 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) { function extractVersionNumber(version) {
if (!version) return 0; if (!version) return 0;
// Nouveau format: "v8" ou "v8-xxx.pwr" // New format: "v8" or "v8-xxx.pwr"
const vMatch = version.match(/v(\d+)/); const vMatch = version.match(/v(\d+)/);
if (vMatch) { if (vMatch) return parseInt(vMatch[1]);
return parseInt(vMatch[1]);
}
// Ancien format: "7.pwr" // Old format: "7.pwr"
const pwrMatch = version.match(/(\d+)\.pwr/); const pwrMatch = version.match(/(\d+)\.pwr/);
if (pwrMatch) { if (pwrMatch) return parseInt(pwrMatch[1]);
return parseInt(pwrMatch[1]);
}
// Fallback: essayer de parser directement // Fallback
const num = parseInt(version); const num = parseInt(version);
return isNaN(num) ? 0 : num; return isNaN(num) ? 0 : num;
} }
@@ -189,7 +243,7 @@ function extractVersionNumber(version) {
function buildArchiveUrl(buildNumber, branch = 'release') { function buildArchiveUrl(buildNumber, branch = 'release') {
const os = getOS(); const os = getOS();
const arch = getArch(); 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') { async function checkArchiveExists(buildNumber, branch = 'release') {
@@ -197,91 +251,56 @@ async function checkArchiveExists(buildNumber, branch = 'release') {
try { try {
const response = await axios.head(url, { timeout: 10000 }); const response = await axios.head(url, { timeout: 10000 });
return response.status === 200; return response.status === 200;
} catch (error) { } catch {
return false; return false;
} }
} }
async function discoverAvailableVersions(latestKnown, branch = 'release', maxProbe = 50) { async function discoverAvailableVersions(latestKnown, branch = 'release') {
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') {
try { try {
const os = getOS(); const manifest = await fetchMirrorManifest();
const arch = getArch(); const patches = getPlatformPatches(manifest, branch);
const response = await smartRequest(`${MANIFEST_API}?branch=${branch}&os=${os}&arch=${arch}`, { const versions = [...new Set(patches.map(p => p.to))].sort((a, b) => b - a);
timeout: 10000 return versions.map(v => `${v}.pwr`);
}); } catch {
return response.data.patches || {}; return [];
} catch (error) {
console.error('Failed to fetch patch manifest:', error.message);
return {};
} }
} }
async function extractVersionDetails(targetVersion, branch = 'release') { async function extractVersionDetails(targetVersion, branch = 'release') {
const buildNumber = extractVersionNumber(targetVersion); const buildNumber = extractVersionNumber(targetVersion);
const previousBuild = buildNumber - 1; const fullUrl = buildArchiveUrl(buildNumber, branch);
const manifest = await fetchPatchManifest(branch);
const patchInfo = manifest[buildNumber];
return { return {
version: targetVersion, version: targetVersion,
buildNumber: buildNumber, buildNumber,
buildName: `HYTALE-Build-${buildNumber}`, buildName: `HYTALE-Build-${buildNumber}`,
fullUrl: patchInfo?.original_url || buildArchiveUrl(buildNumber, branch), fullUrl,
differentialUrl: patchInfo?.patch_url || null, differentialUrl: null,
checksum: patchInfo?.patch_hash || null, checksum: null,
sourceVersion: patchInfo?.from ? `${patchInfo.from}.pwr` : (previousBuild > 0 ? `${previousBuild}.pwr` : null), sourceVersion: null,
isDifferential: !!patchInfo?.proper_patch, isDifferential: false,
releaseNotes: patchInfo?.patch_note || null releaseNotes: null
}; };
} }
function canUseDifferentialUpdate(currentVersion, targetDetails) { function canUseDifferentialUpdate() {
if (!targetDetails) return false; // Differential updates are now handled via getUpdatePlan()
if (!targetDetails.differentialUrl) return false; return false;
if (!targetDetails.isDifferential) return false;
if (!currentVersion) return false;
const currentBuild = extractVersionNumber(currentVersion);
const expectedSource = extractVersionNumber(targetDetails.sourceVersion);
return currentBuild === expectedSource;
} }
function needsIntermediatePatches(currentVersion, targetVersion) { function needsIntermediatePatches(currentVersion, targetVersion) {
if (!currentVersion) return []; if (!currentVersion) return [];
const current = extractVersionNumber(currentVersion); const current = extractVersionNumber(currentVersion);
const target = extractVersionNumber(targetVersion); const target = extractVersionNumber(targetVersion);
if (current >= target) return [];
const intermediates = []; return [targetVersion];
for (let i = current + 1; i <= target; i++) {
intermediates.push(`${i}.pwr`);
}
return intermediates;
} }
async function computeFileChecksum(filePath) { async function computeFileChecksum(filePath) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256'); const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath); const stream = fs.createReadStream(filePath);
stream.on('data', data => hash.update(data)); stream.on('data', data => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex'))); stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject); stream.on('error', reject);
@@ -290,7 +309,6 @@ async function computeFileChecksum(filePath) {
async function validateChecksum(filePath, expectedChecksum) { async function validateChecksum(filePath, expectedChecksum) {
if (!expectedChecksum) return true; if (!expectedChecksum) return true;
const actualChecksum = await computeFileChecksum(filePath); const actualChecksum = await computeFileChecksum(filePath);
return actualChecksum === expectedChecksum; return actualChecksum === expectedChecksum;
} }
@@ -299,7 +317,7 @@ function getInstalledClientVersion() {
try { try {
const { loadVersionClient } = require('../core/config'); const { loadVersionClient } = require('../core/config');
return loadVersionClient(); return loadVersionClient();
} catch (err) { } catch {
return null; return null;
} }
} }
@@ -315,8 +333,12 @@ module.exports = {
computeFileChecksum, computeFileChecksum,
validateChecksum, validateChecksum,
getInstalledClientVersion, getInstalledClientVersion,
fetchNewAPI, fetchMirrorManifest,
getLatestVersionFromNewAPI, getPWRUrl,
getPWRUrlFromNewAPI, getPWRUrlFromNewAPI,
extractVersionNumber getUpdatePlan,
extractVersionNumber,
getPlatformPatches,
findOptimalPatchPath,
MIRROR_BASE_URL
}; };

View File

@@ -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 <staging> <pwr_file> <game_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 <url>`
### 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: <bytes> }`, 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

View File

@@ -1,6 +1,6 @@
{ {
"name": "hytale-f2p-launcher", "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", "description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
"homepage": "https://github.com/amiayweb/Hytale-F2P", "homepage": "https://github.com/amiayweb/Hytale-F2P",
"main": "main.js", "main": "main.js",