|
|
|
@@ -1,22 +1,173 @@
|
|
|
|
const axios = require('axios');
|
|
|
|
const axios = require('axios');
|
|
|
|
const crypto = require('crypto');
|
|
|
|
const crypto = require('crypto');
|
|
|
|
const fs = require('fs');
|
|
|
|
const fs = require('fs');
|
|
|
|
|
|
|
|
const path = require('path');
|
|
|
|
const { getOS, getArch } = require('../utils/platformUtils');
|
|
|
|
const { getOS, getArch } = require('../utils/platformUtils');
|
|
|
|
|
|
|
|
|
|
|
|
// Patches CDN via auth server redirect gateway (allows instant CDN switching)
|
|
|
|
// Patches base URL fetched dynamically via multi-source fallback chain
|
|
|
|
const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws';
|
|
|
|
const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws';
|
|
|
|
const MIRROR_BASE_URL = `https://${AUTH_DOMAIN}/patches`;
|
|
|
|
const PATCHES_CONFIG_SOURCES = [
|
|
|
|
const MIRROR_MANIFEST_URL = `${MIRROR_BASE_URL}/manifest.json`;
|
|
|
|
{ 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
|
|
|
|
// Fallback: latest known build number if manifest is unreachable
|
|
|
|
const FALLBACK_LATEST_BUILD = 11;
|
|
|
|
const FALLBACK_LATEST_BUILD = 11;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let patchesBaseUrl = null;
|
|
|
|
|
|
|
|
let patchesConfigTime = 0;
|
|
|
|
|
|
|
|
const PATCHES_CONFIG_CACHE_DURATION = 300000; // 5 minutes
|
|
|
|
|
|
|
|
|
|
|
|
let manifestCache = null;
|
|
|
|
let manifestCache = null;
|
|
|
|
let manifestCacheTime = 0;
|
|
|
|
let manifestCacheTime = 0;
|
|
|
|
const MANIFEST_CACHE_DURATION = 60000; // 1 minute
|
|
|
|
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 the mirror manifest from MEGA S4
|
|
|
|
* 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() {
|
|
|
|
async function fetchMirrorManifest() {
|
|
|
|
const now = Date.now();
|
|
|
|
const now = Date.now();
|
|
|
|
@@ -26,9 +177,12 @@ async function fetchMirrorManifest() {
|
|
|
|
return manifestCache;
|
|
|
|
return manifestCache;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const baseUrl = await getPatchesBaseUrl();
|
|
|
|
|
|
|
|
const manifestUrl = `${baseUrl}/manifest.json`;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
console.log('[Mirror] Fetching manifest from:', MIRROR_MANIFEST_URL);
|
|
|
|
console.log('[Mirror] Fetching manifest from:', manifestUrl);
|
|
|
|
const response = await axios.get(MIRROR_MANIFEST_URL, {
|
|
|
|
const response = await axios.get(manifestUrl, {
|
|
|
|
timeout: 15000,
|
|
|
|
timeout: 15000,
|
|
|
|
headers: { 'User-Agent': 'Hytale-F2P-Launcher' }
|
|
|
|
headers: { 'User-Agent': 'Hytale-F2P-Launcher' }
|
|
|
|
});
|
|
|
|
});
|
|
|
|
@@ -82,9 +236,10 @@ function getPlatformPatches(manifest, branch = 'release') {
|
|
|
|
* Find optimal patch path using BFS with download size minimization
|
|
|
|
* 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
|
|
|
|
* Returns array of { from, to, url, size, key } steps, or null if no path found
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
function findOptimalPatchPath(currentBuild, targetBuild, patches) {
|
|
|
|
async function findOptimalPatchPath(currentBuild, targetBuild, patches) {
|
|
|
|
if (currentBuild >= targetBuild) return [];
|
|
|
|
if (currentBuild >= targetBuild) return [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const baseUrl = await getPatchesBaseUrl();
|
|
|
|
const edges = {};
|
|
|
|
const edges = {};
|
|
|
|
for (const patch of patches) {
|
|
|
|
for (const patch of patches) {
|
|
|
|
if (!edges[patch.from]) edges[patch.from] = [];
|
|
|
|
if (!edges[patch.from]) edges[patch.from] = [];
|
|
|
|
@@ -118,7 +273,7 @@ function findOptimalPatchPath(currentBuild, targetBuild, patches) {
|
|
|
|
path: [...path, {
|
|
|
|
path: [...path, {
|
|
|
|
from: edge.from,
|
|
|
|
from: edge.from,
|
|
|
|
to: edge.to,
|
|
|
|
to: edge.to,
|
|
|
|
url: `${MIRROR_BASE_URL}/${edge.key}`,
|
|
|
|
url: `${baseUrl}/${edge.key}`,
|
|
|
|
size: edge.size,
|
|
|
|
size: edge.size,
|
|
|
|
key: edge.key
|
|
|
|
key: edge.key
|
|
|
|
}],
|
|
|
|
}],
|
|
|
|
@@ -139,7 +294,7 @@ async function getUpdatePlan(currentBuild, targetBuild, branch = 'release') {
|
|
|
|
const patches = getPlatformPatches(manifest, branch);
|
|
|
|
const patches = getPlatformPatches(manifest, branch);
|
|
|
|
|
|
|
|
|
|
|
|
// Try optimal path
|
|
|
|
// Try optimal path
|
|
|
|
const steps = findOptimalPatchPath(currentBuild, targetBuild, patches);
|
|
|
|
const steps = await findOptimalPatchPath(currentBuild, targetBuild, patches);
|
|
|
|
|
|
|
|
|
|
|
|
if (steps && steps.length > 0) {
|
|
|
|
if (steps && steps.length > 0) {
|
|
|
|
const totalSize = steps.reduce((sum, s) => sum + s.size, 0);
|
|
|
|
const totalSize = steps.reduce((sum, s) => sum + s.size, 0);
|
|
|
|
@@ -150,10 +305,11 @@ async function getUpdatePlan(currentBuild, targetBuild, branch = 'release') {
|
|
|
|
// Fallback: full install 0 -> target
|
|
|
|
// Fallback: full install 0 -> target
|
|
|
|
const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild);
|
|
|
|
const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild);
|
|
|
|
if (fullPatch) {
|
|
|
|
if (fullPatch) {
|
|
|
|
|
|
|
|
const baseUrl = await getPatchesBaseUrl();
|
|
|
|
const step = {
|
|
|
|
const step = {
|
|
|
|
from: 0,
|
|
|
|
from: 0,
|
|
|
|
to: targetBuild,
|
|
|
|
to: targetBuild,
|
|
|
|
url: `${MIRROR_BASE_URL}/${fullPatch.key}`,
|
|
|
|
url: `${baseUrl}/${fullPatch.key}`,
|
|
|
|
size: fullPatch.size,
|
|
|
|
size: fullPatch.size,
|
|
|
|
key: fullPatch.key
|
|
|
|
key: fullPatch.key
|
|
|
|
};
|
|
|
|
};
|
|
|
|
@@ -200,7 +356,8 @@ async function getPWRUrl(branch = 'release', version = 'v11') {
|
|
|
|
const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild);
|
|
|
|
const fullPatch = patches.find(p => p.from === 0 && p.to === targetBuild);
|
|
|
|
|
|
|
|
|
|
|
|
if (fullPatch) {
|
|
|
|
if (fullPatch) {
|
|
|
|
const url = `${MIRROR_BASE_URL}/${fullPatch.key}`;
|
|
|
|
const baseUrl = await getPatchesBaseUrl();
|
|
|
|
|
|
|
|
const url = `${baseUrl}/${fullPatch.key}`;
|
|
|
|
console.log(`[Mirror] PWR URL: ${url}`);
|
|
|
|
console.log(`[Mirror] PWR URL: ${url}`);
|
|
|
|
return url;
|
|
|
|
return url;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -216,7 +373,8 @@ async function getPWRUrl(branch = 'release', version = 'v11') {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Construct mirror URL (will work if patch was uploaded but manifest is stale)
|
|
|
|
// Construct mirror URL (will work if patch was uploaded but manifest is stale)
|
|
|
|
return `${MIRROR_BASE_URL}/${os}/${arch}/${branch}/0_to_${targetBuild}.pwr`;
|
|
|
|
const baseUrl = await getPatchesBaseUrl();
|
|
|
|
|
|
|
|
return `${baseUrl}/${os}/${arch}/${branch}/0_to_${targetBuild}.pwr`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Backward-compatible alias
|
|
|
|
// Backward-compatible alias
|
|
|
|
@@ -240,14 +398,15 @@ function extractVersionNumber(version) {
|
|
|
|
return isNaN(num) ? 0 : num;
|
|
|
|
return isNaN(num) ? 0 : num;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildArchiveUrl(buildNumber, branch = 'release') {
|
|
|
|
async function buildArchiveUrl(buildNumber, branch = 'release') {
|
|
|
|
|
|
|
|
const baseUrl = await getPatchesBaseUrl();
|
|
|
|
const os = getOS();
|
|
|
|
const os = getOS();
|
|
|
|
const arch = getArch();
|
|
|
|
const arch = getArch();
|
|
|
|
return `${MIRROR_BASE_URL}/${os}/${arch}/${branch}/0_to_${buildNumber}.pwr`;
|
|
|
|
return `${baseUrl}/${os}/${arch}/${branch}/0_to_${buildNumber}.pwr`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function checkArchiveExists(buildNumber, branch = 'release') {
|
|
|
|
async function checkArchiveExists(buildNumber, branch = 'release') {
|
|
|
|
const url = buildArchiveUrl(buildNumber, branch);
|
|
|
|
const url = await buildArchiveUrl(buildNumber, branch);
|
|
|
|
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;
|
|
|
|
@@ -269,7 +428,7 @@ async function discoverAvailableVersions(latestKnown, branch = 'release') {
|
|
|
|
|
|
|
|
|
|
|
|
async function extractVersionDetails(targetVersion, branch = 'release') {
|
|
|
|
async function extractVersionDetails(targetVersion, branch = 'release') {
|
|
|
|
const buildNumber = extractVersionNumber(targetVersion);
|
|
|
|
const buildNumber = extractVersionNumber(targetVersion);
|
|
|
|
const fullUrl = buildArchiveUrl(buildNumber, branch);
|
|
|
|
const fullUrl = await buildArchiveUrl(buildNumber, branch);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
version: targetVersion,
|
|
|
|
version: targetVersion,
|
|
|
|
@@ -340,5 +499,5 @@ module.exports = {
|
|
|
|
extractVersionNumber,
|
|
|
|
extractVersionNumber,
|
|
|
|
getPlatformPatches,
|
|
|
|
getPlatformPatches,
|
|
|
|
findOptimalPatchPath,
|
|
|
|
findOptimalPatchPath,
|
|
|
|
MIRROR_BASE_URL
|
|
|
|
getPatchesBaseUrl
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|