const axios = require('axios'); const crypto = require('crypto'); const fs = require('fs'); const { getOS, getArch } = require('../utils/platformUtils'); // Patches CDN via auth server redirect gateway (allows instant CDN switching) const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws'; const MIRROR_BASE_URL = `https://${AUTH_DOMAIN}/patches`; const MIRROR_MANIFEST_URL = `${MIRROR_BASE_URL}/manifest.json`; // Fallback: latest known build number if manifest is unreachable const FALLBACK_LATEST_BUILD = 11; let manifestCache = null; let manifestCacheTime = 0; const MANIFEST_CACHE_DURATION = 60000; // 1 minute /** * Fetch the mirror manifest from MEGA S4 */ async function fetchMirrorManifest() { const now = Date.now(); if (manifestCache && (now - manifestCacheTime) < MANIFEST_CACHE_DURATION) { console.log('[Mirror] Using cached manifest'); return manifestCache; } try { console.log('[Mirror] Fetching manifest from:', MIRROR_MANIFEST_URL); const response = await axios.get(MIRROR_MANIFEST_URL, { timeout: 15000, headers: { 'User-Agent': 'Hytale-F2P-Launcher' } }); 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 */ function findOptimalPatchPath(currentBuild, targetBuild, patches) { if (currentBuild >= targetBuild) return []; const edges = {}; for (const patch of patches) { if (!edges[patch.from]) edges[patch.from] = []; edges[patch.from].push(patch); } const queue = [{ build: currentBuild, path: [], totalSize: 0 }]; let bestPath = null; let bestSize = Infinity; while (queue.length > 0) { const { build, path, totalSize } = queue.shift(); if (build === targetBuild) { if (totalSize < bestSize) { bestPath = path; bestSize = totalSize; } continue; } if (totalSize >= bestSize) continue; const nextEdges = edges[build] || []; for (const edge of nextEdges) { if (edge.to <= build || edge.to > targetBuild) continue; if (path.some(p => p.to === edge.to)) continue; queue.push({ build: edge.to, path: [...path, { from: edge.from, to: edge.to, url: `${MIRROR_BASE_URL}/${edge.key}`, size: edge.size, key: edge.key }], totalSize: totalSize + edge.size }); } } return bestPath; } /** * Get the optimal update plan from currentBuild to targetBuild * Returns { steps: [{from, to, url, size}], totalSize, isFullInstall } */ async function getUpdatePlan(currentBuild, targetBuild, branch = 'release') { const manifest = await fetchMirrorManifest(); const patches = getPlatformPatches(manifest, branch); // Try optimal path const steps = findOptimalPatchPath(currentBuild, targetBuild, patches); if (steps && steps.length > 0) { const totalSize = steps.reduce((sum, s) => sum + s.size, 0); console.log(`[Mirror] Update plan: ${steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')} (${(totalSize / 1024 / 1024).toFixed(0)} MB)`); return { steps, totalSize, isFullInstall: steps.length === 1 && steps[0].from === 0 }; } // Fallback: full install 0 -> target const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild); if (fullPatch) { const step = { from: 0, to: targetBuild, url: `${MIRROR_BASE_URL}/${fullPatch.key}`, size: fullPatch.size, key: fullPatch.key }; console.log(`[Mirror] Full install: 0\u2192${targetBuild} (${(fullPatch.size / 1024 / 1024).toFixed(0)} MB)`); return { steps: [step], totalSize: fullPatch.size, isFullInstall: true }; } throw new Error(`No patch path found from build ${currentBuild} to ${targetBuild} for ${getOS()}/${getArch()}`); } async function getLatestClientVersion(branch = 'release') { try { console.log(`[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 url = `${MIRROR_BASE_URL}/${fullPatch.key}`; console.log(`[Mirror] PWR URL: ${url}`); return url; } if (patches.length > 0) { // Branch exists in mirror but no full patch for this target - construct URL console.log(`[Mirror] No 0->${targetBuild} patch found, constructing URL`); } else { console.log(`[Mirror] Branch '${branch}' not in mirror, constructing URL`); } } catch (error) { console.error('[Mirror] Error getting PWR URL:', error.message); } // Construct mirror URL (will work if patch was uploaded but manifest is stale) return `${MIRROR_BASE_URL}/${os}/${arch}/${branch}/0_to_${targetBuild}.pwr`; } // Backward-compatible alias const getPWRUrlFromNewAPI = getPWRUrl; // Utility function to extract version number // Supports: "7.pwr", "v8", "v8-windows-amd64.pwr", "5_to_10", etc. function extractVersionNumber(version) { if (!version) return 0; // 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; } function buildArchiveUrl(buildNumber, branch = 'release') { const os = getOS(); const arch = getArch(); return `${MIRROR_BASE_URL}/${os}/${arch}/${branch}/0_to_${buildNumber}.pwr`; } async function checkArchiveExists(buildNumber, branch = 'release') { const url = 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 = 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, MIRROR_BASE_URL };