Compare commits

...

7 Commits

Author SHA1 Message Date
sanasol
e7a033932f v2.3.2: fix truncated download cache, update Discord link
Fix pre-release downloads failing with "unexpected EOF" by validating
cached PWR file sizes against manifest expected sizes. Previously only
checked > 1MB which accepted truncated files. Also update Discord
invite link to new server across all files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:14:20 +01:00
sanasol
11c6d40dfe chore: remove private docs from repo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:38:34 +01:00
sanasol
0dafb17c7b 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>
2026-02-20 14:36:09 +01:00
sanasol
66112f15b2 fix: use placeholder publish URL (runtime resolver overrides it)
The actual update URL is resolved dynamically via Forgejo API
in _resolveUpdateUrl() before each update check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:08:30 +01:00
sanasol
0a71fdac8c v2.3.0: migrate auto-update to Forgejo, add Arch build
- Switch auto-update from GitHub to Forgejo (generic provider)
- Dynamically resolve latest release URL via Forgejo API
- Add pacman target to Linux builds
- Hide direct upload URL in repository secret

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:55:35 +01:00
sanasol
4b9eae215b ci: add Arch Linux pacman build and hide upload URL
- Add pacman target to electron-builder Linux build
- Upload .pacman packages to release
- FORGEJO_UPLOAD now uses repository secret

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:45:07 +01:00
sanasol
1510eceb0f Hide direct upload IP in repository secret
Move FORGEJO_UPLOAD URL from hardcoded value to ${{ secrets.FORGEJO_UPLOAD_URL }}
to avoid exposing server IP when repo goes public.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:35:53 +01:00
17 changed files with 569 additions and 394 deletions

View File

@@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when
## Enforcement ## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [Discord Server, message Founders/Devs](https://discord.gg/hf2pdc). All complaints will be reviewed and investigated promptly and fairly. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [Discord Server, message Founders/Devs](https://discord.gg/Fhbb9Yk5WW). All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident. All community leaders are obligated to respect the privacy and security of the reporter of any incident.

View File

@@ -22,7 +22,7 @@ body:
value: | value: |
If you need help or support with using the launcher, please fill out this support request. If you need help or support with using the launcher, please fill out this support request.
Provide as much detail as possible so we can assist you effectively. Provide as much detail as possible so we can assist you effectively.
**Need a quick assistance?** Please Open-A-Ticket in our [Discord Server](https://discord.gg/gME8rUy3MB)! **Need a quick assistance?** Please Open-A-Ticket in our [Discord Server](https://discord.gg/Fhbb9Yk5WW)!
- type: textarea - type: textarea
id: question id: question

View File

@@ -9,8 +9,8 @@ on:
env: env:
# Domain for small API calls (goes through Cloudflare - fine for <100MB) # Domain for small API calls (goes through Cloudflare - fine for <100MB)
FORGEJO_API: https://git.sanhost.net/api/v1 FORGEJO_API: https://git.sanhost.net/api/v1
# Direct to Forgejo port (bypasses Cloudflare + Traefik for large uploads) # Direct upload URL (bypasses Cloudflare for large files) - set in repo secrets
FORGEJO_UPLOAD: http://208.69.78.130:3001/api/v1 FORGEJO_UPLOAD: ${{ secrets.FORGEJO_UPLOAD_URL }}
jobs: jobs:
create-release: create-release:
@@ -107,13 +107,13 @@ jobs:
- run: npm ci - run: npm ci
- name: Build Linux Packages - name: Build Linux Packages
run: npx electron-builder --linux AppImage deb rpm --publish never run: npx electron-builder --linux AppImage deb rpm pacman --publish never
- name: Upload to Release - name: Upload to Release
run: | run: |
RELEASE_ID=$(curl -s "${FORGEJO_API}/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \ RELEASE_ID=$(curl -s "${FORGEJO_API}/repos/${GITHUB_REPOSITORY}/releases/tags/${{ github.ref_name }}" \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])') -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')
for file in dist/*.AppImage dist/*.AppImage.blockmap dist/*.deb dist/*.rpm dist/latest-linux.yml; do for file in dist/*.AppImage dist/*.AppImage.blockmap dist/*.deb dist/*.rpm dist/*.pacman dist/latest-linux.yml; do
[ -f "$file" ] || continue [ -f "$file" ] || continue
echo "Uploading $file..." echo "Uploading $file..."
curl -s --max-time 600 -X POST "${FORGEJO_UPLOAD}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename $file)" \ curl -s --max-time 600 -X POST "${FORGEJO_UPLOAD}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename $file)" \

3
.gitignore vendored
View File

@@ -17,6 +17,9 @@ dist/
# Project Specific: Downloaded patcher (from hytale-auth-server) # Project Specific: Downloaded patcher (from hytale-auth-server)
backend/patcher/ backend/patcher/
# Private docs (local only)
docs/PATCH_CDN_INFRASTRUCTURE.md
# macOS Specific # macOS Specific
.DS_Store .DS_Store
*.zst.DS_Store *.zst.DS_Store

View File

@@ -53,7 +53,7 @@ window.closeDiscordPopup = function() {
}; };
window.joinDiscord = async function() { window.joinDiscord = async function() {
await window.electronAPI?.openExternal('https://discord.gg/hf2pdc'); await window.electronAPI?.openExternal('https://discord.gg/Fhbb9Yk5WW');
try { try {
await window.electronAPI?.saveConfig({ discordPopup: true }); await window.electronAPI?.saveConfig({ discordPopup: true });

View File

@@ -1103,7 +1103,7 @@ function getRetryContextMessage() {
} }
window.openDiscordExternal = function() { window.openDiscordExternal = function() {
window.electronAPI?.openExternal('https://discord.gg/hf2pdc'); window.electronAPI?.openExternal('https://discord.gg/Fhbb9Yk5WW');
}; };
window.toggleMaximize = toggleMaximize; window.toggleMaximize = toggleMaximize;

View File

@@ -18,7 +18,7 @@
### ⚠️ **WARNING: READ [QUICK START](#-quick-start) before Downloading & Installing the Launcher!** ⚠️ ### ⚠️ **WARNING: READ [QUICK START](#-quick-start) before Downloading & Installing the Launcher!** ⚠️
#### 🛑 **Found a problem? [Join the HF2P Discord](https://discord.gg/hf2pdc) and head to `#-⚠️-community-help`** 🛑 #### 🛑 **Found a problem? [Join the HF2P Discord](https://discord.gg/Fhbb9Yk5WW) and head to `#-⚠️-community-help`** 🛑
<p> <p>
👍 If you like the project, <b>feel free to support us via Buy Me a Coffee!</b> ☕<br> 👍 If you like the project, <b>feel free to support us via Buy Me a Coffee!</b> ☕<br>
@@ -455,7 +455,7 @@ See [BUILD.md](docs/BUILD.md) for comprehensive build instructions.
<div align="center"> <div align="center">
**Questions? Ads? Collaboration? Endorsement? Other business-related?** **Questions? Ads? Collaboration? Endorsement? Other business-related?**
Message the founders at https://discord.gg/hf2pdc Message the founders at https://discord.gg/Fhbb9Yk5WW
</div> </div>

View File

@@ -2,7 +2,7 @@
Play with friends online! This guide covers both easy in-game hosting and advanced dedicated server setup. Play with friends online! This guide covers both easy in-game hosting and advanced dedicated server setup.
### **DOWNLOAD SERVER FILES (JAR/RAR/SCRIPTS) HERE: https://discord.gg/hf2pdc** ### **DOWNLOAD SERVER FILES (JAR/RAR/SCRIPTS) HERE: https://discord.gg/Fhbb9Yk5WW**
**Table of Contents** **Table of Contents**

View File

@@ -1,6 +1,6 @@
# Hytale F2P Launcher - Troubleshooting Guide # Hytale F2P Launcher - Troubleshooting Guide
This guide covers common issues and their solutions. If your issue isn't listed here, please check [existing issues](https://github.com/amiayweb/Hytale-F2P/issues) or join our [Discord](https://discord.gg/gME8rUy3MB). This guide covers common issues and their solutions. If your issue isn't listed here, please check [existing issues](https://github.com/amiayweb/Hytale-F2P/issues) or join our [Discord](https://discord.gg/Fhbb9Yk5WW).
--- ---
@@ -437,7 +437,7 @@ Game sessions have a 10-hour TTL. This is by design for security.
If your issue isn't resolved by this guide: If your issue isn't resolved by this guide:
1. **Check existing issues:** [GitHub Issues](https://github.com/amiayweb/Hytale-F2P/issues) 1. **Check existing issues:** [GitHub Issues](https://github.com/amiayweb/Hytale-F2P/issues)
2. **Join Discord:** [discord.gg/gME8rUy3MB](https://discord.gg/gME8rUy3MB) 2. **Join Discord:** [discord.gg/Fhbb9Yk5WW](https://discord.gg/Fhbb9Yk5WW)
3. **Open a new issue** with: 3. **Open a new issue** with:
- Your operating system and version - Your operating system and version
- Launcher version - Launcher version

View File

@@ -4,6 +4,10 @@ const logger = require('./logger');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const https = require('https');
const FORGEJO_API = 'https://git.sanhost.net/api/v1';
const FORGEJO_REPO = 'sanasol/hytale-f2p';
class AppUpdater { class AppUpdater {
constructor(mainWindow) { constructor(mainWindow) {
@@ -14,6 +18,34 @@ class AppUpdater {
this.setupAutoUpdater(); this.setupAutoUpdater();
} }
/**
* Fetch the latest non-draft release tag from Forgejo and set the feed URL
*/
async _resolveUpdateUrl() {
return new Promise((resolve, reject) => {
https.get(`${FORGEJO_API}/repos/${FORGEJO_REPO}/releases?limit=5`, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
try {
const releases = JSON.parse(data);
const latest = releases.find(r => !r.draft && !r.prerelease);
if (latest) {
const url = `https://git.sanhost.net/${FORGEJO_REPO}/releases/download/${latest.tag_name}`;
console.log(`Auto-update URL resolved to: ${url}`);
autoUpdater.setFeedURL({ provider: 'generic', url });
resolve(url);
} else {
reject(new Error('No published release found'));
}
} catch (e) {
reject(e);
}
});
}).on('error', reject);
});
}
setupAutoUpdater() { setupAutoUpdater() {
// Configure logger for electron-updater // Configure logger for electron-updater
@@ -216,8 +248,10 @@ class AppUpdater {
} }
checkForUpdatesAndNotify() { checkForUpdatesAndNotify() {
// Check for updates and notify if available // Resolve latest release URL then check for updates
autoUpdater.checkForUpdatesAndNotify().catch(err => { this._resolveUpdateUrl().catch(err => {
console.warn('Failed to resolve update URL:', err.message);
}).then(() => autoUpdater.checkForUpdatesAndNotify()).catch(err => {
console.error('Failed to check for updates:', err); console.error('Failed to check for updates:', err);
// Network errors are not critical - just log and continue // Network errors are not critical - just log and continue
@@ -245,8 +279,10 @@ class AppUpdater {
} }
checkForUpdates() { checkForUpdates() {
// Manual check for updates (returns promise) // Manual check - resolve latest release URL first
return autoUpdater.checkForUpdates().catch(err => { return this._resolveUpdateUrl().catch(err => {
console.warn('Failed to resolve update URL:', err.message);
}).then(() => autoUpdater.checkForUpdates()).catch(err => {
console.error('Failed to check for updates:', err); console.error('Failed to check for updates:', err);
// Network errors are not critical - just return no update available // Network errors are not critical - just return no update available

View File

@@ -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');
@@ -30,7 +30,7 @@ async function acquireGameArchive(downloadUrl, targetPath, checksum, progressCal
} }
console.log(`Downloading game archive from: ${downloadUrl}`); console.log(`Downloading game archive from: ${downloadUrl}`);
try { try {
if (allowRetry) { if (allowRetry) {
await retryDownload(downloadUrl, targetPath, progressCallback); await retryDownload(downloadUrl, targetPath, progressCallback);
@@ -103,13 +103,13 @@ async function deployGameArchive(archivePath, destinationDir, toolsDir, progress
if (error) { if (error) {
const cleanStderr = stderr.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim(); const cleanStderr = stderr.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim();
const cleanStdout = stdout.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim(); const cleanStdout = stdout.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim();
if (cleanStderr) console.error('Deployment stderr:', cleanStderr); if (cleanStderr) console.error('Deployment stderr:', cleanStderr);
if (cleanStdout) console.error('Deployment stdout:', cleanStdout); if (cleanStdout) console.error('Deployment stdout:', cleanStdout);
const errorText = (stderr + ' ' + error.message).toLowerCase(); const errorText = (stderr + ' ' + error.message).toLowerCase();
let message = 'Game deployment failed'; let message = 'Game deployment failed';
if (errorText.includes('unexpected eof')) { if (errorText.includes('unexpected eof')) {
message = 'Corrupted archive detected. Please retry download.'; message = 'Corrupted archive detected. Please retry download.';
if (fs.existsSync(archivePath)) { if (fs.existsSync(archivePath)) {
@@ -156,20 +156,20 @@ 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);
} }
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);
saveVersionClient(targetVersion); saveVersionClient(targetVersion);
@@ -177,16 +177,16 @@ 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);
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false); await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false);
saveVersionClient(targetVersion); saveVersionClient(targetVersion);
@@ -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);
console.log(`Applying ${plan.steps.length} patch(es): ${plan.steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')}`);
for (let i = 0; i < plan.steps.length; i++) {
const step = plan.steps[i];
const stepName = `${step.from}_to_${step.to}`;
const archivePath = path.join(cacheDir, `${branch}_${stepName}.pwr`);
const isDifferential = step.from !== 0;
for (let i = 0; i < patchesToApply.length; i++) {
const patchVersion = patchesToApply[i];
const versionDetails = await extractVersionDetails(patchVersion, branch);
const canDifferential = canUseDifferentialUpdate(getInstalledClientVersion(), versionDetails);
if (!canDifferential || !versionDetails.differentialUrl) {
console.log(`WARNING: Differential patch not available for ${patchVersion}, using full archive`);
const archiveName = path.basename(versionDetails.fullUrl);
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(versionDetails.fullUrl, archivePath, null, progressCallback); await acquireGameArchive(step.url, archivePath, null, progressCallback);
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false);
} else {
console.log(`Applying differential patch: ${versionDetails.sourceVersion} -> ${patchVersion}`);
const archiveName = path.basename(versionDetails.differentialUrl);
const archivePath = path.join(cacheDir, `${branch}_patch_${archiveName}`);
if (progressCallback) { if (progressCallback) {
progressCallback(`Applying patch ${i + 1}/${patchesToApply.length}: ${patchVersion}...`, 0, null, null, null); progressCallback(`Applying patch ${i + 1}/${plan.steps.length}: ${stepName}...`, 50, null, null, null);
} }
await acquireGameArchive(versionDetails.differentialUrl, archivePath, versionDetails.checksum, progressCallback); await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, isDifferential);
await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, true);
// Clean up patch file
if (fs.existsSync(archivePath)) { if (fs.existsSync(archivePath)) {
try { try {
fs.unlinkSync(archivePath); fs.unlinkSync(archivePath);
console.log(`Cleaned up patch file: ${archiveName}`); console.log(`Cleaned up: ${stepName}.pwr`);
} catch (cleanupErr) { } catch (cleanupErr) {
console.warn(`Failed to cleanup patch file: ${cleanupErr.message}`); console.warn(`Failed to cleanup: ${cleanupErr.message}`);
} }
} }
}
saveVersionClient(patchVersion);
console.log(`Patch ${patchVersion} applied successfully (${i + 1}/${patchesToApply.length})`);
}
console.log(`Update completed successfully. Version ${targetVersion} is now installed.`); 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 deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false);
saveVersionClient(targetVersion);
}
} }
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) {

View File

@@ -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;
return { // Verify the identity token has the correct username
identityToken: data.IdentityToken || data.identityToken, // This catches cases where the auth server defaults to "Player"
sessionToken: data.SessionToken || data.sessionToken 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 {
identityToken: retryData.IdentityToken || retryData.identityToken,
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

View File

@@ -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, expectedSize = null) {
const osName = getOS(); const osName = getOS();
const arch = getArch(); const arch = getArch();
@@ -72,43 +72,68 @@ 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) {
try { url = directUrl;
console.log(`[DownloadPWR] Fetching URL from new API for branch: ${branch}, version: ${fileName}`); console.log(`[DownloadPWR] Using direct URL: ${url}`);
url = await getPWRUrlFromNewAPI(branch, fileName); } else {
isUsingNewAPI = true; const { getPWRUrl } = require('../services/versionManager');
console.log(`[DownloadPWR] Using new API URL: ${url}`); try {
} catch (error) { console.log(`[DownloadPWR] Fetching mirror URL for branch: ${branch}, version: ${fileName}`);
console.error(`[DownloadPWR] Failed to get URL from new API: ${error.message}`); url = await getPWRUrl(branch, fileName);
console.log(`[DownloadPWR] Falling back to old URL format`); console.log(`[DownloadPWR] Mirror URL: ${url}`);
url = `https://game-patches.hytale.com/patches/${osName}/${arch}/${branch}/0/${fileName}.pwr`; } catch (error) {
console.error(`[DownloadPWR] Failed to get mirror URL: ${error.message}`);
const { MIRROR_BASE_URL } = require('../services/versionManager');
url = `${MIRROR_BASE_URL}/${osName}/${arch}/${branch}/0_to_${extractVersionNumber(fileName)}.pwr`;
console.log(`[DownloadPWR] Fallback URL: ${url}`);
}
} }
// Look up expected file size from manifest if not provided
if (!expectedSize) {
try {
const { fetchMirrorManifest } = require('../services/versionManager');
const manifest = await fetchMirrorManifest();
// Try to match: "0_to_11" format or "v11" format
const versionMatch = fileName.match(/^(\d+)_to_(\d+)$/);
let manifestKey;
if (versionMatch) {
manifestKey = `${osName}/${arch}/${branch}/${fileName}.pwr`;
} else {
const buildNum = extractVersionNumber(fileName);
manifestKey = `${osName}/${arch}/${branch}/0_to_${buildNum}.pwr`;
}
if (manifest.files[manifestKey]) {
expectedSize = manifest.files[manifestKey].size;
console.log(`[PWR] Expected size from manifest: ${(expectedSize / 1024 / 1024).toFixed(2)} MB`);
}
} catch (e) {
console.log(`[PWR] Could not fetch expected size from manifest: ${e.message}`);
}
}
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; // Validate against expected size - reject if file is truncated (< 99% of expected)
} if (expectedSize && stats.size < expectedSize * 0.99) {
console.log(`[PWR] Cached file truncated: ${(stats.size / 1024 / 1024).toFixed(2)} MB, expected ${(expectedSize / 1024 / 1024).toFixed(2)} MB. Deleting and re-downloading.`);
// Check if file is under 1.5 GB (incomplete download) fs.unlinkSync(dest);
const sizeInMB = stats.size / 1024 / 1024; } else {
if (sizeInMB < 1500) { console.log(`[PWR] Using cached file: ${dest} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
console.log(`[PWR Validation] File appears incomplete: ${sizeInMB.toFixed(2)} MB < 1.5 GB`); return dest;
return false; }
} else {
console.log(`[PWR] Cached file too small (${stats.size} bytes), re-downloading`);
} }
} }
console.log(`Fetching PWR patch file from ${isUsingNewAPI ? 'NEW API' : 'old API'}:`, url); console.log(`[DownloadPWR] Downloading from: ${url}`);
try { try {
if (manualRetry) { if (manualRetry) {
@@ -134,7 +159,7 @@ async function downloadPWR(branch = 'release', fileName = 'v8', progressCallback
const retryStats = fs.statSync(dest); const retryStats = fs.statSync(dest);
console.log(`PWR file downloaded (auto-retry), size: ${(retryStats.size / 1024 / 1024).toFixed(2)} MB`); console.log(`PWR file downloaded (auto-retry), size: ${(retryStats.size / 1024 / 1024).toFixed(2)} MB`);
if (!validatePWRFile(dest)) { if (!validatePWRFile(dest, expectedSize)) {
console.log(`[PWR Validation] PWR file validation failed after auto-retry, deleting corrupted file: ${dest}`); console.log(`[PWR Validation] PWR file validation failed after auto-retry, deleting corrupted file: ${dest}`);
fs.unlinkSync(dest); fs.unlinkSync(dest);
throw new Error('Downloaded PWR file is corrupted or invalid after automatic retry. Please retry manually'); throw new Error('Downloaded PWR file is corrupted or invalid after automatic retry. Please retry manually');
@@ -184,8 +209,8 @@ async function downloadPWR(branch = 'release', fileName = 'v8', progressCallback
// Enhanced PWR file validation // Enhanced PWR file validation
const stats = fs.statSync(dest); const stats = fs.statSync(dest);
console.log(`PWR file downloaded, size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`); console.log(`PWR file downloaded, size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
if (!validatePWRFile(dest)) { if (!validatePWRFile(dest, expectedSize)) {
console.log(`[PWR Validation] PWR file validation failed, deleting corrupted file: ${dest}`); console.log(`[PWR Validation] PWR file validation failed, deleting corrupted file: ${dest}`);
fs.unlinkSync(dest); fs.unlinkSync(dest);
throw new Error('Downloaded PWR file is corrupted or invalid. Please retry'); throw new Error('Downloaded PWR file is corrupted or invalid. Please retry');
@@ -203,7 +228,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,11 +252,12 @@ 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');
const clientPath = findClientPath(gameLatest); if (!skipExistingCheck) {
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
@@ -412,57 +438,118 @@ 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})`);
tempUpdateDir = path.join(gameDir, '..', 'temp_update'); // Determine update strategy: intermediate patches vs full reinstall
const currentVersion = loadVersionClient();
const currentBuild = extractVersionNumber(currentVersion) || 0;
const targetBuild = extractVersionNumber(newVersion);
if (fs.existsSync(tempUpdateDir)) { let useIntermediatePatches = false;
fs.rmSync(tempUpdateDir, { recursive: true, force: true }); let updatePlan = null;
}
fs.mkdirSync(tempUpdateDir, { recursive: true });
if (progressCallback) { if (currentBuild > 0 && currentBuild < targetBuild) {
progressCallback('Downloading new game version...', 20, null, null, null); try {
} updatePlan = await getUpdatePlan(currentBuild, targetBuild, branch);
useIntermediatePatches = !updatePlan.isFullInstall;
const pwrFile = await downloadPWR(branch, newVersion, progressCallback, cacheDir); if (useIntermediatePatches) {
const totalMB = (updatePlan.totalSize / 1024 / 1024).toFixed(0);
if (progressCallback) { console.log(`[UpdateGameFiles] Using intermediate patches: ${updatePlan.steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')} (${totalMB} MB)`);
progressCallback('Extracting new files...', 60, null, null, null); }
} } catch (planError) {
console.warn('[UpdateGameFiles] Could not get update plan, falling back to full install:', planError.message);
await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir, branch, cacheDir);
// Delete PWR file from cache after successful update
try {
if (fs.existsSync(pwrFile)) {
fs.unlinkSync(pwrFile);
console.log('[UpdateGameFiles] PWR file deleted from cache after successful update:', pwrFile);
} }
} catch (delErr) {
console.warn('[UpdateGameFiles] Failed to delete PWR file from cache:', delErr.message);
}
if (progressCallback) {
progressCallback('Replacing game files...', 80, null, null, null);
} }
if (fs.existsSync(gameDir)) { if (useIntermediatePatches && updatePlan) {
console.log('Removing old game files...'); // Apply intermediate patches directly to game dir
let retries = 3; for (let i = 0; i < updatePlan.steps.length; i++) {
while (retries > 0) { 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, step.size);
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 { try {
fs.rmSync(gameDir, { recursive: true, force: true }); if (fs.existsSync(pwrFile)) {
break; fs.unlinkSync(pwrFile);
} catch (err) { }
if ((err.code === 'EPERM' || err.code === 'EBUSY') && retries > 0) { } catch (delErr) {
retries--; console.warn('[UpdateGameFiles] Failed to delete PWR from cache:', delErr.message);
console.log(`[UpdateGameFiles] Removal failed with ${err.code}, retrying in 1s... (${retries} retries left)`); }
await new Promise(resolve => setTimeout(resolve, 1000));
} else { // Save intermediate version so we can resume if interrupted
throw err; 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');
if (fs.existsSync(tempUpdateDir)) {
fs.rmSync(tempUpdateDir, { recursive: true, force: true });
}
fs.mkdirSync(tempUpdateDir, { recursive: true });
if (progressCallback) {
progressCallback('Downloading new game version...', 20, null, null, null);
}
const pwrFile = await downloadPWR(branch, newVersion, progressCallback, cacheDir);
if (progressCallback) {
progressCallback('Extracting new files...', 60, null, null, null);
}
await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir, branch, cacheDir);
try {
if (fs.existsSync(pwrFile)) {
fs.unlinkSync(pwrFile);
console.log('[UpdateGameFiles] PWR file deleted from cache after successful update:', pwrFile);
}
} catch (delErr) {
console.warn('[UpdateGameFiles] Failed to delete PWR file from cache:', delErr.message);
}
if (progressCallback) {
progressCallback('Replacing game files...', 80, null, null, null);
}
if (fs.existsSync(gameDir)) {
console.log('Removing old game files...');
let retries = 3;
while (retries > 0) {
try {
fs.rmSync(gameDir, { recursive: true, force: true });
break;
} catch (err) {
if ((err.code === 'EPERM' || err.code === 'EBUSY') && retries > 0) {
retries--;
console.log(`[UpdateGameFiles] Removal failed with ${err.code}, retrying in 1s... (${retries} retries left)`);
await new Promise(resolve => setTimeout(resolve, 1000));
} else {
throw err;
}
} }
} }
} }
}
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,36 +920,30 @@ function validateGameDirectory(gameDir, stagingDir) {
} }
// Enhanced PWR file validation // Enhanced PWR file validation
function validatePWRFile(filePath) { // Accepts intermediate patches (50+ MB) and full installs (1.5+ GB)
function validatePWRFile(filePath, expectedSize = null) {
try { try {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return false; return false;
} }
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) // Validate against expected size if known (reject if < 99% of expected)
if (sizeInMB < 1500) { if (expectedSize && stats.size < expectedSize * 0.99) {
console.log(`[PWR Validation] File appears incomplete: ${sizeInMB.toFixed(2)} MB < 1.5 GB`); const expectedMB = expectedSize / 1024 / 1024;
console.log(`[PWR Validation] File truncated: ${sizeInMB.toFixed(2)} MB, expected ${expectedMB.toFixed(2)} MB`);
return false; return false;
} }
// Basic file header validation (PWR files should have specific headers) console.log(`[PWR Validation] File size: ${sizeInMB.toFixed(2)} MB - OK`);
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);

View File

@@ -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(); */
const arch = getArch(); function getPlatformPatches(manifest, branch = 'release') {
const os = getOS();
let osKey = osName; const arch = getArch();
if (osName === 'darwin') { const prefix = `${os}/${arch}/${branch}/`;
osKey = 'mac'; 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
});
}
} }
const branchData = apiData.hytale[branch];
if (!branchData || !branchData[osKey]) {
throw new Error(`No data found for branch: ${branch}, OS: ${osKey}`);
}
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;
} }
return patches;
} }
async function getPWRUrlFromNewAPI(branch = 'release', version = 'v8') { /**
try { * Find optimal patch path using BFS with download size minimization
const apiData = await fetchNewAPI(); * Returns array of { from, to, url, size, key } steps, or null if no path found
const osName = getOS(); */
const arch = getArch(); function findOptimalPatchPath(currentBuild, targetBuild, patches) {
if (currentBuild >= targetBuild) return [];
let osKey = osName;
if (osName === 'darwin') { const edges = {};
osKey = 'mac'; for (const patch of patches) {
} if (!edges[patch.from]) edges[patch.from] = [];
edges[patch.from].push(patch);
let fileName;
if (osName === 'windows') {
fileName = `${version}-windows-amd64.pwr`;
} else if (osName === 'linux') {
fileName = `${version}-linux-amd64.pwr`;
} else if (osName === 'darwin') {
fileName = `${version}-darwin-arm64.pwr`;
}
const branchData = apiData.hytale[branch];
if (!branchData || !branchData[osKey]) {
throw new Error(`No data found for branch: ${branch}, OS: ${osKey}`);
}
const osData = branchData[osKey];
const url = osData[fileName];
if (!url) {
throw new Error(`No URL found for ${fileName}`);
}
console.log(`[NewAPI] URL for ${fileName}: ${url}`);
return url;
} catch (error) {
console.error('[NewAPI] Error getting PWR URL:', error.message);
throw error;
} }
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') { 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();
// Utiliser la nouvelle API const patches = getPlatformPatches(manifest, branch);
const latestVersion = await getLatestVersionFromNewAPI(branch);
console.log(`[NewAPI] Latest client version for ${branch}: ${latestVersion}`);
return latestVersion;
} catch (error) {
console.error('[NewAPI] Error fetching client version from new API:', error.message);
console.log('[NewAPI] Falling back to old API...');
// Fallback vers l'ancienne API si la nouvelle échoue
try {
const response = await smartRequest(`https://files.hytalef2p.com/api/version_client?branch=${branch}`, {
timeout: 40000,
headers: {
'User-Agent': 'Hytale-F2P-Launcher'
}
});
if (response.data && response.data.client_version) { if (patches.length === 0) {
const version = response.data.client_version; console.log(`[Mirror] No patches for branch '${branch}', using fallback`);
console.log(`Latest client version for ${branch} (old API): ${version}`); return `v${FALLBACK_LATEST_BUILD}`;
return version;
} else {
console.log('Warning: Invalid API response, falling back to latest known version (v8)');
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';
} }
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}`;
} }
} }
// Fonction utilitaire pour extraire le numéro de version /**
// Supporte les formats: "7.pwr", "v8", "v8-windows-amd64.pwr", etc. * 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) { 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]);
} // Old format: "7.pwr"
// Ancien 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
// Fallback: essayer de parser directement
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
}; };

View File

@@ -1,3 +1,2 @@
provider: github provider: generic
owner: amiayweb # Change to your own GitHub username url: https://git.sanhost.net/sanasol/hytale-f2p/releases/download/latest
repo: Hytale-F2P

View File

@@ -89,7 +89,7 @@ function setDiscordActivity() {
}, },
{ {
label: 'Discord', label: 'Discord',
url: 'https://discord.gg/hf2pdc' url: 'https://discord.gg/Fhbb9Yk5WW'
} }
] ]
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "hytale-f2p-launcher", "name": "hytale-f2p-launcher",
"version": "2.2.2", "version": "2.3.2",
"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",
@@ -118,9 +118,8 @@
"createStartMenuShortcut": true "createStartMenuShortcut": true
}, },
"publish": { "publish": {
"provider": "github", "provider": "generic",
"owner": "amiayweb", "url": "https://git.sanhost.net/sanasol/hytale-f2p/releases/download/latest"
"repo": "Hytale-F2P"
} }
} }
} }