const axios = require('axios'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { getOS, getArch } = require('../utils/platformUtils'); // Patches base URL fetched dynamically via multi-source fallback chain const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws'; const PATCHES_CONFIG_SOURCES = [ { type: 'http', url: `https://${AUTH_DOMAIN}/api/patches-config`, name: 'auth-server' }, { type: 'http', url: 'https://htdwnldsan.top/patches-config', name: 'backup-http' }, { type: 'doh', name: '_patches.htdwnldsan.top', name_label: 'dns-txt' }, ]; const HARDCODED_FALLBACK = 'https://dl.vboro.de/patches'; // 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 // Disk cache path for patches URL (survives restarts) function getDiskCachePath() { const os = require('os'); const home = os.homedir(); let appDir; if (process.platform === 'win32') { appDir = path.join(home, 'AppData', 'Local', 'HytaleF2P'); } else if (process.platform === 'darwin') { appDir = path.join(home, 'Library', 'Application Support', 'HytaleF2P'); } else { appDir = path.join(home, '.hytalef2p'); } return path.join(appDir, 'patches-url-cache.json'); } function saveDiskCache(url) { try { const cachePath = getDiskCachePath(); const dir = path.dirname(cachePath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(cachePath, JSON.stringify({ patches_url: url, ts: Date.now() }), 'utf8'); } catch (e) { // Non-critical, ignore } } function loadDiskCache() { try { const cachePath = getDiskCachePath(); if (fs.existsSync(cachePath)) { const data = JSON.parse(fs.readFileSync(cachePath, 'utf8')); if (data && data.patches_url) return data.patches_url; } } catch (e) { // Non-critical, ignore } return null; } /** * Fetch patches URL from a single HTTP config endpoint */ async function fetchFromHttp(url) { const response = await axios.get(url, { timeout: 8000, headers: { 'User-Agent': 'Hytale-F2P-Launcher' } }); if (response.data && response.data.patches_url) { return response.data.patches_url.replace(/\/+$/, ''); } throw new Error('Invalid response'); } /** * Fetch patches URL from DNS TXT record via DNS-over-HTTPS */ async function fetchFromDoh(recordName) { const dohEndpoints = [ { url: 'https://dns.google/resolve', params: { name: recordName, type: 'TXT' } }, { url: 'https://cloudflare-dns.com/dns-query', params: { name: recordName, type: 'TXT' }, headers: { 'Accept': 'application/dns-json' } }, ]; for (const endpoint of dohEndpoints) { try { const response = await axios.get(endpoint.url, { params: endpoint.params, headers: { 'User-Agent': 'Hytale-F2P-Launcher', ...(endpoint.headers || {}) }, timeout: 5000 }); const answers = response.data && response.data.Answer; if (answers && answers.length > 0) { // TXT records are quoted, strip quotes const txt = answers[0].data.replace(/^"|"$/g, ''); if (txt.startsWith('http')) return txt.replace(/\/+$/, ''); } } catch (e) { // Try next DoH endpoint } } throw new Error('All DoH endpoints failed'); } /** * Fetch patches base URL with hardened multi-source fallback chain: * 1. Memory cache (5 min) * 2. HTTP: auth.sanasol.ws (primary) * 3. HTTP: htdwnldsan.top (backup, different host/domain/registrar) * 4. DNS TXT: _patches.htdwnldsan.top via DoH (different protocol layer) * 5. Disk cache (survives restarts, never expires) * 6. Hardcoded fallback URL (last resort) */ async function getPatchesBaseUrl() { const now = Date.now(); // 1. Memory cache if (patchesBaseUrl && (now - patchesConfigTime) < PATCHES_CONFIG_CACHE_DURATION) { return patchesBaseUrl; } // 2-4. Try all sources: HTTP endpoints first, then DoH for (const source of PATCHES_CONFIG_SOURCES) { try { let url; if (source.type === 'http') { console.log(`[Mirror] Trying ${source.name}: ${source.url}`); url = await fetchFromHttp(source.url); } else if (source.type === 'doh') { console.log(`[Mirror] Trying ${source.name_label}: ${source.name}`); url = await fetchFromDoh(source.name); } if (url) { patchesBaseUrl = url; patchesConfigTime = now; saveDiskCache(url); console.log(`[Mirror] Patches URL (via ${source.name || source.name_label}): ${url}`); return url; } } catch (e) { console.warn(`[Mirror] ${source.name || source.name_label} failed: ${e.message}`); } } // 5. Stale memory cache (any age) if (patchesBaseUrl) { console.log('[Mirror] All sources failed, using stale memory cache:', patchesBaseUrl); return patchesBaseUrl; } // 6. Disk cache (survives restarts) const diskUrl = loadDiskCache(); if (diskUrl) { patchesBaseUrl = diskUrl; console.log('[Mirror] All sources failed, using disk cache:', diskUrl); return diskUrl; } // 7. Hardcoded fallback console.warn('[Mirror] All sources + caches exhausted, using hardcoded fallback:', HARDCODED_FALLBACK); patchesBaseUrl = HARDCODED_FALLBACK; return HARDCODED_FALLBACK; } /** * 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 };