mirror of
https://git.sanhost.net/sanasol/hytale-f2p.git
synced 2026-02-26 06:41:47 -03:00
Launcher now fetches patches base URL from /api/patches-config endpoint instead of using hardcoded domain. URL cached for 5 minutes, no fallback to hardcoded domain - requires auth server connection or cached URL. Enables instant CDN switching without launcher updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
389 lines
12 KiB
JavaScript
389 lines
12 KiB
JavaScript
const axios = require('axios');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const { getOS, getArch } = require('../utils/platformUtils');
|
|
|
|
// Patches base URL fetched dynamically from auth server
|
|
const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws';
|
|
const PATCHES_CONFIG_URL = `https://${AUTH_DOMAIN}/api/patches-config`;
|
|
|
|
// Fallback: latest known build number if manifest is unreachable
|
|
const FALLBACK_LATEST_BUILD = 11;
|
|
|
|
let patchesBaseUrl = null;
|
|
let patchesConfigTime = 0;
|
|
const PATCHES_CONFIG_CACHE_DURATION = 300000; // 5 minutes
|
|
|
|
let manifestCache = null;
|
|
let manifestCacheTime = 0;
|
|
const MANIFEST_CACHE_DURATION = 60000; // 1 minute
|
|
|
|
/**
|
|
* Fetch patches base URL from auth server config endpoint
|
|
*/
|
|
async function getPatchesBaseUrl() {
|
|
const now = Date.now();
|
|
if (patchesBaseUrl && (now - patchesConfigTime) < PATCHES_CONFIG_CACHE_DURATION) {
|
|
return patchesBaseUrl;
|
|
}
|
|
|
|
try {
|
|
console.log('[Mirror] Fetching patches config from:', PATCHES_CONFIG_URL);
|
|
const response = await axios.get(PATCHES_CONFIG_URL, {
|
|
timeout: 10000,
|
|
headers: { 'User-Agent': 'Hytale-F2P-Launcher' }
|
|
});
|
|
|
|
if (response.data && response.data.patches_url) {
|
|
patchesBaseUrl = response.data.patches_url.replace(/\/+$/, '');
|
|
patchesConfigTime = now;
|
|
console.log('[Mirror] Patches base URL:', patchesBaseUrl);
|
|
return patchesBaseUrl;
|
|
}
|
|
throw new Error('Invalid patches config');
|
|
} catch (error) {
|
|
console.error('[Mirror] Error fetching patches config:', error.message);
|
|
if (patchesBaseUrl) {
|
|
console.log('[Mirror] Using cached patches URL:', patchesBaseUrl);
|
|
return patchesBaseUrl;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch the mirror manifest
|
|
*/
|
|
async function fetchMirrorManifest() {
|
|
const now = Date.now();
|
|
|
|
if (manifestCache && (now - manifestCacheTime) < MANIFEST_CACHE_DURATION) {
|
|
console.log('[Mirror] Using cached manifest');
|
|
return manifestCache;
|
|
}
|
|
|
|
const baseUrl = await getPatchesBaseUrl();
|
|
const manifestUrl = `${baseUrl}/manifest.json`;
|
|
|
|
try {
|
|
console.log('[Mirror] Fetching manifest from:', manifestUrl);
|
|
const response = await axios.get(manifestUrl, {
|
|
timeout: 15000,
|
|
headers: { 'User-Agent': 'Hytale-F2P-Launcher' }
|
|
});
|
|
|
|
if (response.data && response.data.files) {
|
|
manifestCache = response.data;
|
|
manifestCacheTime = now;
|
|
console.log('[Mirror] Manifest fetched successfully');
|
|
return response.data;
|
|
}
|
|
throw new Error('Invalid manifest structure');
|
|
} catch (error) {
|
|
console.error('[Mirror] Error fetching manifest:', error.message);
|
|
if (manifestCache) {
|
|
console.log('[Mirror] Using expired cache');
|
|
return manifestCache;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse manifest to get available patches for current platform
|
|
* Returns array of { from, to, key, size }
|
|
*/
|
|
function getPlatformPatches(manifest, branch = 'release') {
|
|
const os = getOS();
|
|
const arch = getArch();
|
|
const prefix = `${os}/${arch}/${branch}/`;
|
|
const patches = [];
|
|
|
|
for (const [key, info] of Object.entries(manifest.files)) {
|
|
if (key.startsWith(prefix) && key.endsWith('.pwr')) {
|
|
const filename = key.slice(prefix.length, -4); // e.g., "0_to_11"
|
|
const match = filename.match(/^(\d+)_to_(\d+)$/);
|
|
if (match) {
|
|
patches.push({
|
|
from: parseInt(match[1]),
|
|
to: parseInt(match[2]),
|
|
key,
|
|
size: info.size
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return patches;
|
|
}
|
|
|
|
/**
|
|
* Find optimal patch path using BFS with download size minimization
|
|
* Returns array of { from, to, url, size, key } steps, or null if no path found
|
|
*/
|
|
async function findOptimalPatchPath(currentBuild, targetBuild, patches) {
|
|
if (currentBuild >= targetBuild) return [];
|
|
|
|
const baseUrl = await getPatchesBaseUrl();
|
|
const edges = {};
|
|
for (const patch of patches) {
|
|
if (!edges[patch.from]) edges[patch.from] = [];
|
|
edges[patch.from].push(patch);
|
|
}
|
|
|
|
const queue = [{ build: currentBuild, path: [], totalSize: 0 }];
|
|
let bestPath = null;
|
|
let bestSize = Infinity;
|
|
|
|
while (queue.length > 0) {
|
|
const { build, path, totalSize } = queue.shift();
|
|
|
|
if (build === targetBuild) {
|
|
if (totalSize < bestSize) {
|
|
bestPath = path;
|
|
bestSize = totalSize;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (totalSize >= bestSize) continue;
|
|
|
|
const nextEdges = edges[build] || [];
|
|
for (const edge of nextEdges) {
|
|
if (edge.to <= build || edge.to > targetBuild) continue;
|
|
if (path.some(p => p.to === edge.to)) continue;
|
|
|
|
queue.push({
|
|
build: edge.to,
|
|
path: [...path, {
|
|
from: edge.from,
|
|
to: edge.to,
|
|
url: `${baseUrl}/${edge.key}`,
|
|
size: edge.size,
|
|
key: edge.key
|
|
}],
|
|
totalSize: totalSize + edge.size
|
|
});
|
|
}
|
|
}
|
|
|
|
return bestPath;
|
|
}
|
|
|
|
/**
|
|
* Get the optimal update plan from currentBuild to targetBuild
|
|
* Returns { steps: [{from, to, url, size}], totalSize, isFullInstall }
|
|
*/
|
|
async function getUpdatePlan(currentBuild, targetBuild, branch = 'release') {
|
|
const manifest = await fetchMirrorManifest();
|
|
const patches = getPlatformPatches(manifest, branch);
|
|
|
|
// Try optimal path
|
|
const steps = await 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 baseUrl = await getPatchesBaseUrl();
|
|
const step = {
|
|
from: 0,
|
|
to: targetBuild,
|
|
url: `${baseUrl}/${fullPatch.key}`,
|
|
size: fullPatch.size,
|
|
key: fullPatch.key
|
|
};
|
|
console.log(`[Mirror] Full install: 0\u2192${targetBuild} (${(fullPatch.size / 1024 / 1024).toFixed(0)} MB)`);
|
|
return { steps: [step], totalSize: fullPatch.size, isFullInstall: true };
|
|
}
|
|
|
|
throw new Error(`No patch path found from build ${currentBuild} to ${targetBuild} for ${getOS()}/${getArch()}`);
|
|
}
|
|
|
|
async function getLatestClientVersion(branch = 'release') {
|
|
try {
|
|
console.log(`[Mirror] Fetching latest client version (branch: ${branch})...`);
|
|
const manifest = await fetchMirrorManifest();
|
|
const patches = getPlatformPatches(manifest, branch);
|
|
|
|
if (patches.length === 0) {
|
|
console.log(`[Mirror] No patches for branch '${branch}', using fallback`);
|
|
return `v${FALLBACK_LATEST_BUILD}`;
|
|
}
|
|
|
|
const latestBuild = Math.max(...patches.map(p => p.to));
|
|
console.log(`[Mirror] Latest client version: v${latestBuild}`);
|
|
return `v${latestBuild}`;
|
|
} catch (error) {
|
|
console.error('[Mirror] Error:', error.message);
|
|
return `v${FALLBACK_LATEST_BUILD}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 baseUrl = await getPatchesBaseUrl();
|
|
const url = `${baseUrl}/${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)
|
|
const baseUrl = await getPatchesBaseUrl();
|
|
return `${baseUrl}/${os}/${arch}/${branch}/0_to_${targetBuild}.pwr`;
|
|
}
|
|
|
|
// Backward-compatible alias
|
|
const getPWRUrlFromNewAPI = getPWRUrl;
|
|
|
|
// Utility function to extract version number
|
|
// Supports: "7.pwr", "v8", "v8-windows-amd64.pwr", "5_to_10", etc.
|
|
function extractVersionNumber(version) {
|
|
if (!version) return 0;
|
|
|
|
// New format: "v8" or "v8-xxx.pwr"
|
|
const vMatch = version.match(/v(\d+)/);
|
|
if (vMatch) return parseInt(vMatch[1]);
|
|
|
|
// Old format: "7.pwr"
|
|
const pwrMatch = version.match(/(\d+)\.pwr/);
|
|
if (pwrMatch) return parseInt(pwrMatch[1]);
|
|
|
|
// Fallback
|
|
const num = parseInt(version);
|
|
return isNaN(num) ? 0 : num;
|
|
}
|
|
|
|
async function buildArchiveUrl(buildNumber, branch = 'release') {
|
|
const baseUrl = await getPatchesBaseUrl();
|
|
const os = getOS();
|
|
const arch = getArch();
|
|
return `${baseUrl}/${os}/${arch}/${branch}/0_to_${buildNumber}.pwr`;
|
|
}
|
|
|
|
async function checkArchiveExists(buildNumber, branch = 'release') {
|
|
const url = await buildArchiveUrl(buildNumber, branch);
|
|
try {
|
|
const response = await axios.head(url, { timeout: 10000 });
|
|
return response.status === 200;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function discoverAvailableVersions(latestKnown, branch = 'release') {
|
|
try {
|
|
const manifest = await fetchMirrorManifest();
|
|
const patches = getPlatformPatches(manifest, branch);
|
|
const versions = [...new Set(patches.map(p => p.to))].sort((a, b) => b - a);
|
|
return versions.map(v => `${v}.pwr`);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function extractVersionDetails(targetVersion, branch = 'release') {
|
|
const buildNumber = extractVersionNumber(targetVersion);
|
|
const fullUrl = await buildArchiveUrl(buildNumber, branch);
|
|
|
|
return {
|
|
version: targetVersion,
|
|
buildNumber,
|
|
buildName: `HYTALE-Build-${buildNumber}`,
|
|
fullUrl,
|
|
differentialUrl: null,
|
|
checksum: null,
|
|
sourceVersion: null,
|
|
isDifferential: false,
|
|
releaseNotes: null
|
|
};
|
|
}
|
|
|
|
function canUseDifferentialUpdate() {
|
|
// Differential updates are now handled via getUpdatePlan()
|
|
return false;
|
|
}
|
|
|
|
function needsIntermediatePatches(currentVersion, targetVersion) {
|
|
if (!currentVersion) return [];
|
|
const current = extractVersionNumber(currentVersion);
|
|
const target = extractVersionNumber(targetVersion);
|
|
if (current >= target) return [];
|
|
return [targetVersion];
|
|
}
|
|
|
|
async function computeFileChecksum(filePath) {
|
|
return new Promise((resolve, reject) => {
|
|
const hash = crypto.createHash('sha256');
|
|
const stream = fs.createReadStream(filePath);
|
|
stream.on('data', data => hash.update(data));
|
|
stream.on('end', () => resolve(hash.digest('hex')));
|
|
stream.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function validateChecksum(filePath, expectedChecksum) {
|
|
if (!expectedChecksum) return true;
|
|
const actualChecksum = await computeFileChecksum(filePath);
|
|
return actualChecksum === expectedChecksum;
|
|
}
|
|
|
|
function getInstalledClientVersion() {
|
|
try {
|
|
const { loadVersionClient } = require('../core/config');
|
|
return loadVersionClient();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getLatestClientVersion,
|
|
buildArchiveUrl,
|
|
checkArchiveExists,
|
|
discoverAvailableVersions,
|
|
extractVersionDetails,
|
|
canUseDifferentialUpdate,
|
|
needsIntermediatePatches,
|
|
computeFileChecksum,
|
|
validateChecksum,
|
|
getInstalledClientVersion,
|
|
fetchMirrorManifest,
|
|
getPWRUrl,
|
|
getPWRUrlFromNewAPI,
|
|
getUpdatePlan,
|
|
extractVersionNumber,
|
|
getPlatformPatches,
|
|
findOptimalPatchPath,
|
|
getPatchesBaseUrl
|
|
};
|