mirror of
https://git.sanhost.net/sanasol/hytale-f2p.git
synced 2026-02-26 06:41:47 -03:00
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:
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPWRUrlFromNewAPI(branch = 'release', version = 'v8') {
|
return patches;
|
||||||
try {
|
|
||||||
const apiData = await fetchNewAPI();
|
|
||||||
const osName = getOS();
|
|
||||||
const arch = getArch();
|
|
||||||
|
|
||||||
let osKey = osName;
|
|
||||||
if (osName === 'darwin') {
|
|
||||||
osKey = 'mac';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileName;
|
/**
|
||||||
if (osName === 'windows') {
|
* Find optimal patch path using BFS with download size minimization
|
||||||
fileName = `${version}-windows-amd64.pwr`;
|
* Returns array of { from, to, url, size, key } steps, or null if no path found
|
||||||
} else if (osName === 'linux') {
|
*/
|
||||||
fileName = `${version}-linux-amd64.pwr`;
|
function findOptimalPatchPath(currentBuild, targetBuild, patches) {
|
||||||
} else if (osName === 'darwin') {
|
if (currentBuild >= targetBuild) return [];
|
||||||
fileName = `${version}-darwin-arm64.pwr`;
|
|
||||||
|
const edges = {};
|
||||||
|
for (const patch of patches) {
|
||||||
|
if (!edges[patch.from]) edges[patch.from] = [];
|
||||||
|
edges[patch.from].push(patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
const branchData = apiData.hytale[branch];
|
const queue = [{ build: currentBuild, path: [], totalSize: 0 }];
|
||||||
if (!branchData || !branchData[osKey]) {
|
let bestPath = null;
|
||||||
throw new Error(`No data found for branch: ${branch}, OS: ${osKey}`);
|
let bestSize = Infinity;
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const { build, path, totalSize } = queue.shift();
|
||||||
|
|
||||||
|
if (build === targetBuild) {
|
||||||
|
if (totalSize < bestSize) {
|
||||||
|
bestPath = path;
|
||||||
|
bestSize = totalSize;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const osData = branchData[osKey];
|
if (totalSize >= bestSize) continue;
|
||||||
const url = osData[fileName];
|
|
||||||
|
|
||||||
if (!url) {
|
const nextEdges = edges[build] || [];
|
||||||
throw new Error(`No URL found for ${fileName}`);
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[NewAPI] URL for ${fileName}: ${url}`);
|
return bestPath;
|
||||||
return url;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[NewAPI] Error getting PWR URL:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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') {
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
|
||||||
// Fallback vers l'ancienne API si la nouvelle échoue
|
|
||||||
try {
|
try {
|
||||||
const response = await smartRequest(`https://files.hytalef2p.com/api/version_client?branch=${branch}`, {
|
const manifest = await fetchMirrorManifest();
|
||||||
timeout: 40000,
|
const patches = getPlatformPatches(manifest, branch);
|
||||||
headers: {
|
const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild);
|
||||||
'User-Agent': 'Hytale-F2P-Launcher'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data && response.data.client_version) {
|
if (fullPatch) {
|
||||||
const version = response.data.client_version;
|
const url = `${MIRROR_BASE_URL}/${fullPatch.key}`;
|
||||||
console.log(`Latest client version for ${branch} (old API): ${version}`);
|
console.log(`[Mirror] PWR URL: ${url}`);
|
||||||
return version;
|
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 {
|
} else {
|
||||||
console.log('Warning: Invalid API response, falling back to latest known version (v8)');
|
console.log(`[Mirror] Branch '${branch}' not in mirror, constructing URL`);
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mirror] Error getting PWR URL:', error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fonction utilitaire pour extraire le numéro de version
|
// Construct mirror URL (will work if patch was uploaded but manifest is stale)
|
||||||
// Supporte les formats: "7.pwr", "v8", "v8-windows-amd64.pwr", etc.
|
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
|
||||||
};
|
};
|
||||||
|
|||||||
217
docs/PATCH_CDN_INFRASTRUCTURE.md
Normal file
217
docs/PATCH_CDN_INFRASTRUCTURE.md
Normal 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
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user