Merge pull request #213 from amiayweb/fix/update-system-improvements

This commit is contained in:
AMIAY
2026-01-28 03:14:05 +01:00
committed by GitHub
6 changed files with 514 additions and 536 deletions

View File

@@ -882,7 +882,7 @@
<script src="js/i18n.js"></script> <script src="js/i18n.js"></script>
<script type="module" src="js/settings.js"></script> <script type="module" src="js/settings.js"></script>
<script type="module" src="js/update.js"></script> <script type="module" src="js/update.js"></script>
<script src="js/updater.js"></script> <!-- updater.js disabled - using update.js instead which has skip button and macOS handling -->
</body> </body>

View File

@@ -72,8 +72,11 @@ export async function installGame() {
setTimeout(() => { setTimeout(() => {
window.LauncherUI.hideProgress(); window.LauncherUI.hideProgress();
window.LauncherUI.showLauncherOrInstall(true); window.LauncherUI.showLauncherOrInstall(true);
// Sync player name to both launcher and settings inputs
const playerNameInput = document.getElementById('playerName'); const playerNameInput = document.getElementById('playerName');
if (playerNameInput) playerNameInput.value = playerName; if (playerNameInput) playerNameInput.value = playerName;
const settingsPlayerName = document.getElementById('settingsPlayerName');
if (settingsPlayerName) settingsPlayerName.value = playerName;
resetInstallButton(); resetInstallButton();
}, 2000); }, 2000);
} }
@@ -125,8 +128,11 @@ function simulateInstallation(playerName) {
setTimeout(() => { setTimeout(() => {
window.LauncherUI.hideProgress(); window.LauncherUI.hideProgress();
window.LauncherUI.showLauncherOrInstall(true); window.LauncherUI.showLauncherOrInstall(true);
// Sync player name to both launcher and settings inputs
const playerNameInput = document.getElementById('playerName'); const playerNameInput = document.getElementById('playerName');
if (playerNameInput) playerNameInput.value = playerName; if (playerNameInput) playerNameInput.value = playerName;
const settingsPlayerName = document.getElementById('settingsPlayerName');
if (settingsPlayerName) settingsPlayerName.value = playerName;
resetInstallButton(); resetInstallButton();
}, 2000); }, 2000);
} }
@@ -246,9 +252,3 @@ document.addEventListener('DOMContentLoaded', async () => {
setupInstallation(); setupInstallation();
await checkGameStatusAndShowInterface(); await checkGameStatusAndShowInterface();
}); });
window.browseInstallPath = browseInstallPath;
document.addEventListener('DOMContentLoaded', async () => {
setupInstallation();
await checkGameStatusAndShowInterface();
});

View File

@@ -6,12 +6,12 @@ class ClientUpdateManager {
} }
init() { init() {
window.electronAPI.onUpdatePopup((updateInfo) => { console.log('🔧 ClientUpdateManager initializing...');
this.showUpdatePopup(updateInfo);
});
// Listen for electron-updater events // Listen for electron-updater events from main.js
// This is the primary update trigger - main.js checks for updates on startup
window.electronAPI.onUpdateAvailable((updateInfo) => { window.electronAPI.onUpdateAvailable((updateInfo) => {
console.log('📥 update-available event received:', updateInfo);
this.showUpdatePopup(updateInfo); this.showUpdatePopup(updateInfo);
}); });
@@ -20,18 +20,30 @@ class ClientUpdateManager {
}); });
window.electronAPI.onUpdateDownloaded((updateInfo) => { window.electronAPI.onUpdateDownloaded((updateInfo) => {
console.log('📦 update-downloaded event received:', updateInfo);
this.showUpdateDownloaded(updateInfo); this.showUpdateDownloaded(updateInfo);
}); });
window.electronAPI.onUpdateError((errorInfo) => { window.electronAPI.onUpdateError((errorInfo) => {
console.log('❌ update-error event received:', errorInfo);
this.handleUpdateError(errorInfo); this.handleUpdateError(errorInfo);
}); });
this.checkForUpdatesOnDemand(); console.log('✅ ClientUpdateManager initialized');
// Note: Don't call checkForUpdatesOnDemand() here - main.js already checks
// for updates after 3 seconds and sends 'update-available' event.
// Calling it here would cause duplicate popups.
} }
showUpdatePopup(updateInfo) { showUpdatePopup(updateInfo) {
if (this.updatePopupVisible) return; console.log('🔔 showUpdatePopup called, updatePopupVisible:', this.updatePopupVisible);
// Check if popup already exists in DOM (extra safety)
if (this.updatePopupVisible || document.getElementById('update-popup-overlay')) {
console.log('⚠️ Update popup already visible, skipping');
return;
}
this.updatePopupVisible = true; this.updatePopupVisible = true;
@@ -92,7 +104,10 @@ class ClientUpdateManager {
</div> </div>
<div class="update-popup-footer"> <div class="update-popup-footer">
This popup cannot be closed until you update the launcher <span id="update-footer-text">Downloading update...</span>
<button id="update-skip-btn" class="update-skip-btn" style="display: none; margin-top: 0.5rem; background: transparent; border: 1px solid rgba(255,255,255,0.2); color: #9ca3af; padding: 0.5rem 1rem; border-radius: 0.25rem; cursor: pointer; font-size: 0.75rem;">
Skip for now (not recommended)
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -119,10 +134,37 @@ class ClientUpdateManager {
try { try {
await window.electronAPI.quitAndInstallUpdate(); await window.electronAPI.quitAndInstallUpdate();
// If we're still here after 5 seconds, the install probably failed
setTimeout(() => {
console.log('⚠️ Install may have failed - showing skip option');
installBtn.disabled = false;
installBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Try Again';
// Show skip button
const skipBtn = document.getElementById('update-skip-btn');
const footerText = document.getElementById('update-footer-text');
if (skipBtn) {
skipBtn.style.display = 'inline-block';
if (footerText) {
footerText.textContent = 'Install not working? Skip for now:';
}
}
}, 5000);
} catch (error) { } catch (error) {
console.error('❌ Error installing update:', error); console.error('❌ Error installing update:', error);
installBtn.disabled = false; installBtn.disabled = false;
installBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Install & Restart'; installBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Install & Restart';
// Show skip button on error
const skipBtn = document.getElementById('update-skip-btn');
const footerText = document.getElementById('update-footer-text');
if (skipBtn) {
skipBtn.style.display = 'inline-block';
if (footerText) {
footerText.textContent = 'Install failed. Skip for now:';
}
}
} }
}); });
} }
@@ -138,9 +180,14 @@ class ClientUpdateManager {
try { try {
await window.electronAPI.openDownloadPage(); await window.electronAPI.openDownloadPage();
console.log('✅ Download page opened, launcher will close...'); console.log('✅ Download page opened');
downloadBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Launcher closing...'; downloadBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Opened in browser';
// Close the popup after opening download page
setTimeout(() => {
this.closeUpdatePopup();
}, 1500);
} catch (error) { } catch (error) {
console.error('❌ Error opening download page:', error); console.error('❌ Error opening download page:', error);
@@ -161,9 +208,39 @@ class ClientUpdateManager {
}); });
} }
// Show skip button after 30 seconds as fallback (in case update is stuck)
setTimeout(() => {
const skipBtn = document.getElementById('update-skip-btn');
const footerText = document.getElementById('update-footer-text');
if (skipBtn) {
skipBtn.style.display = 'inline-block';
if (footerText) {
footerText.textContent = 'Update taking too long?';
}
}
}, 30000);
const skipBtn = document.getElementById('update-skip-btn');
if (skipBtn) {
skipBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.closeUpdatePopup();
});
}
console.log('🔔 Update popup displayed with new style'); console.log('🔔 Update popup displayed with new style');
} }
closeUpdatePopup() {
const overlay = document.getElementById('update-popup-overlay');
if (overlay) {
overlay.remove();
}
this.updatePopupVisible = false;
this.unblockInterface();
}
updateDownloadProgress(progress) { updateDownloadProgress(progress) {
const progressBar = document.getElementById('update-progress-bar'); const progressBar = document.getElementById('update-progress-bar');
const progressPercent = document.getElementById('update-progress-percent'); const progressPercent = document.getElementById('update-progress-percent');
@@ -197,25 +274,86 @@ class ClientUpdateManager {
const statusText = document.getElementById('update-status-text'); const statusText = document.getElementById('update-status-text');
const progressContainer = document.getElementById('update-progress-container'); const progressContainer = document.getElementById('update-progress-container');
const buttonsContainer = document.getElementById('update-buttons-container'); const buttonsContainer = document.getElementById('update-buttons-container');
const installBtn = document.getElementById('update-install-btn');
const downloadBtn = document.getElementById('update-download-btn');
const skipBtn = document.getElementById('update-skip-btn');
const footerText = document.getElementById('update-footer-text');
const popupContainer = document.querySelector('.update-popup-container');
if (statusText) { // Remove breathing/pulse animation when download is complete
statusText.textContent = 'Update downloaded! Ready to install.'; if (popupContainer) {
popupContainer.classList.remove('update-popup-pulse');
} }
if (progressContainer) { if (progressContainer) {
progressContainer.style.display = 'none'; progressContainer.style.display = 'none';
} }
// Use platform info from main process if available, fallback to browser detection
const autoInstallSupported = updateInfo.autoInstallSupported !== undefined
? updateInfo.autoInstallSupported
: navigator.platform.toUpperCase().indexOf('MAC') < 0;
if (!autoInstallSupported) {
// macOS: Show manual download as primary since auto-update doesn't work
if (statusText) {
statusText.textContent = 'Update downloaded but auto-install may not work on macOS.';
}
if (installBtn) {
// Still show install button but as secondary option
installBtn.classList.add('update-download-btn-secondary');
installBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Try Install & Restart';
}
if (downloadBtn) {
// Make manual download primary
downloadBtn.classList.remove('update-download-btn-secondary');
downloadBtn.innerHTML = '<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>Download Manually (Recommended)';
}
if (footerText) {
footerText.textContent = 'Auto-install often fails on macOS:';
}
} else {
// Windows/Linux: Auto-install should work
if (statusText) {
statusText.textContent = 'Update downloaded! Ready to install.';
}
if (footerText) {
footerText.textContent = 'Click to install the update:';
}
}
if (buttonsContainer) { if (buttonsContainer) {
buttonsContainer.style.display = 'block'; buttonsContainer.style.display = 'block';
} }
console.log('✅ Update downloaded, ready to install'); // Always show skip button in downloaded state
if (skipBtn) {
skipBtn.style.display = 'inline-block';
console.log('✅ Skip button made visible');
} else {
console.error('❌ Skip button not found in DOM!');
}
console.log('✅ Update downloaded, ready to install. autoInstallSupported:', autoInstallSupported);
} }
handleUpdateError(errorInfo) { handleUpdateError(errorInfo) {
console.error('Update error:', errorInfo); console.error('Update error:', errorInfo);
// Show skip button immediately on any error
const skipBtn = document.getElementById('update-skip-btn');
const footerText = document.getElementById('update-footer-text');
if (skipBtn) {
skipBtn.style.display = 'inline-block';
if (footerText) {
footerText.textContent = 'Update failed. You can skip for now.';
}
}
// If manual download is required, update the UI (this will handle status text) // If manual download is required, update the UI (this will handle status text)
if (errorInfo.requiresManualDownload) { if (errorInfo.requiresManualDownload) {
this.showManualDownloadRequired(errorInfo); this.showManualDownloadRequired(errorInfo);
@@ -289,6 +427,16 @@ class ClientUpdateManager {
buttonsContainer.style.display = 'block'; buttonsContainer.style.display = 'block';
} }
// Show skip button for manual download errors
const skipBtn = document.getElementById('update-skip-btn');
const footerText = document.getElementById('update-footer-text');
if (skipBtn) {
skipBtn.style.display = 'inline-block';
if (footerText) {
footerText.textContent = 'Or continue without updating:';
}
}
console.log('⚠️ Manual download required due to update error'); console.log('⚠️ Manual download required due to update error');
} }
@@ -300,13 +448,35 @@ class ClientUpdateManager {
document.body.classList.add('no-select'); document.body.classList.add('no-select');
document.addEventListener('keydown', this.blockKeyEvents.bind(this), true); // Store bound functions so we can remove them later
this._boundBlockKeyEvents = this.blockKeyEvents.bind(this);
this._boundBlockContextMenu = this.blockContextMenu.bind(this);
document.addEventListener('contextmenu', this.blockContextMenu.bind(this), true); document.addEventListener('keydown', this._boundBlockKeyEvents, true);
document.addEventListener('contextmenu', this._boundBlockContextMenu, true);
console.log('🚫 Interface blocked for update'); console.log('🚫 Interface blocked for update');
} }
unblockInterface() {
const mainContent = document.querySelector('.flex.w-full.h-screen');
if (mainContent) {
mainContent.classList.remove('interface-blocked');
}
document.body.classList.remove('no-select');
// Remove event listeners
if (this._boundBlockKeyEvents) {
document.removeEventListener('keydown', this._boundBlockKeyEvents, true);
}
if (this._boundBlockContextMenu) {
document.removeEventListener('contextmenu', this._boundBlockContextMenu, true);
}
console.log('✅ Interface unblocked');
}
blockKeyEvents(event) { blockKeyEvents(event) {
if (event.target.closest('#update-popup-overlay')) { if (event.target.closest('#update-popup-overlay')) {
if ((event.key === 'Enter' || event.key === ' ') && if ((event.key === 'Enter' || event.key === ' ') &&

View File

@@ -1,10 +1,5 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto');
const AdmZip = require('adm-zip');
const { execSync, spawn } = require('child_process');
const { getJavaExec, getBundledJavaPath } = require('../managers/javaManager');
const { JRE_DIR } = require('../core/paths');
// Domain configuration // Domain configuration
const ORIGINAL_DOMAIN = 'hytale.com'; const ORIGINAL_DOMAIN = 'hytale.com';
@@ -26,15 +21,13 @@ function getTargetDomain() {
const DEFAULT_NEW_DOMAIN = 'auth.sanasol.ws'; const DEFAULT_NEW_DOMAIN = 'auth.sanasol.ws';
/** /**
* Patches HytaleClient and HytaleServer binaries to replace hytale.com with custom domain * Patches HytaleClient binary to replace hytale.com with custom domain
* This allows the game to connect to a custom authentication server * Server patching is done via pre-patched JAR download from CDN
* *
* Supports domains from 4 to 16 characters: * Supports domains from 4 to 16 characters:
* - All F2P traffic routes to single endpoint: https://{domain} (no subdomains) * - All F2P traffic routes to single endpoint: https://{domain} (no subdomains)
* - Domains <= 10 chars: Direct replacement, subdomains stripped * - Domains <= 10 chars: Direct replacement, subdomains stripped
* - Domains 11-16 chars: Split mode - first 6 chars replace subdomain prefix, rest replaces domain * - Domains 11-16 chars: Split mode - first 6 chars replace subdomain prefix, rest replaces domain
*
* Official hytale.com keeps original subdomain behavior (sessions., account-data., etc.)
*/ */
class ClientPatcher { class ClientPatcher {
constructor() { constructor() {
@@ -61,19 +54,16 @@ class ClientPatcher {
/** /**
* Calculate the domain patching strategy based on length * Calculate the domain patching strategy based on length
* @returns {object} Strategy with mainDomain and subdomainPrefix
*/ */
getDomainStrategy(domain) { getDomainStrategy(domain) {
if (domain.length <= 10) { if (domain.length <= 10) {
// Direct replacement - subdomains will be stripped
return { return {
mode: 'direct', mode: 'direct',
mainDomain: domain, mainDomain: domain,
subdomainPrefix: '', // Empty = subdomains stripped subdomainPrefix: '',
description: `Direct replacement: hytale.com -> ${domain}` description: `Direct replacement: hytale.com -> ${domain}`
}; };
} else { } else {
// Split mode: first 6 chars become subdomain prefix, rest replaces hytale.com
const prefix = domain.slice(0, 6); const prefix = domain.slice(0, 6);
const suffix = domain.slice(6); const suffix = domain.slice(6);
return { return {
@@ -87,21 +77,15 @@ class ClientPatcher {
/** /**
* Convert a string to the length-prefixed byte format used by the client * Convert a string to the length-prefixed byte format used by the client
* Format: [length byte] [00 00 00 padding] [char1] [00] [char2] [00] ... [lastChar]
* Note: No null byte after the last character
*/ */
stringToLengthPrefixed(str) { stringToLengthPrefixed(str) {
const length = str.length; const length = str.length;
const result = Buffer.alloc(4 + length + (length - 1)); // length byte + padding + chars + separators const result = Buffer.alloc(4 + length + (length - 1));
// Length byte
result[0] = length; result[0] = length;
// Padding: 00 00 00
result[1] = 0x00; result[1] = 0x00;
result[2] = 0x00; result[2] = 0x00;
result[3] = 0x00; result[3] = 0x00;
// Characters with null separators (no separator after last char)
let pos = 4; let pos = 4;
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
result[pos++] = str.charCodeAt(i); result[pos++] = str.charCodeAt(i);
@@ -109,7 +93,6 @@ class ClientPatcher {
result[pos++] = 0x00; result[pos++] = 0x00;
} }
} }
return result; return result;
} }
@@ -124,13 +107,6 @@ class ClientPatcher {
return buf; return buf;
} }
/**
* Convert a string to UTF-8 bytes (how Java stores strings)
*/
stringToUtf8(str) {
return Buffer.from(str, 'utf8');
}
/** /**
* Find all occurrences of a pattern in a buffer * Find all occurrences of a pattern in a buffer
*/ */
@@ -148,7 +124,6 @@ class ClientPatcher {
/** /**
* Replace bytes in buffer - only overwrites the length of new bytes * Replace bytes in buffer - only overwrites the length of new bytes
* Prevents offset corruption by not expanding the replacement
*/ */
replaceBytes(buffer, oldBytes, newBytes) { replaceBytes(buffer, oldBytes, newBytes) {
let count = 0; let count = 0;
@@ -160,9 +135,7 @@ class ClientPatcher {
} }
const positions = this.findAllOccurrences(result, oldBytes); const positions = this.findAllOccurrences(result, oldBytes);
for (const pos of positions) { for (const pos of positions) {
// Only overwrite the length of the new bytes
newBytes.copy(result, pos); newBytes.copy(result, pos);
count++; count++;
} }
@@ -171,32 +144,7 @@ class ClientPatcher {
} }
/** /**
* UTF-8 domain replacement for Java JAR files. * Smart domain replacement that handles both null-terminated and non-null-terminated strings
* Java stores strings in UTF-8 format in the constant pool.
*/
findAndReplaceDomainUtf8(data, oldDomain, newDomain) {
let count = 0;
const result = Buffer.from(data);
const oldUtf8 = this.stringToUtf8(oldDomain);
const newUtf8 = this.stringToUtf8(newDomain);
const positions = this.findAllOccurrences(result, oldUtf8);
for (const pos of positions) {
newUtf8.copy(result, pos);
count++;
console.log(` Patched UTF-8 occurrence at offset 0x${pos.toString(16)}`);
}
return { buffer: result, count };
}
/**
* Smart domain replacement that handles both null-terminated and non-null-terminated strings.
* .NET AOT stores some strings in various formats:
* - Standard UTF-16LE (each char is 2 bytes with \x00 high byte)
* - Length-prefixed where last char may have metadata byte instead of \x00
*/ */
findAndReplaceDomainSmart(data, oldDomain, newDomain) { findAndReplaceDomainSmart(data, oldDomain, newDomain) {
let count = 0; let count = 0;
@@ -218,7 +166,6 @@ class ClientPatcher {
if (lastCharFirstByte === oldLastCharByte) { if (lastCharFirstByte === oldLastCharByte) {
newUtf16NoLast.copy(result, pos); newUtf16NoLast.copy(result, pos);
result[lastCharPos] = newLastCharByte; result[lastCharPos] = newLastCharByte;
if (lastCharPos + 1 < result.length) { if (lastCharPos + 1 < result.length) {
@@ -238,7 +185,6 @@ class ClientPatcher {
/** /**
* Apply all domain patches using length-prefixed format * Apply all domain patches using length-prefixed format
* This is the main patching method for variable-length domains
*/ */
applyDomainPatches(data, domain, protocol = 'https://') { applyDomainPatches(data, domain, protocol = 'https://') {
let result = Buffer.from(data); let result = Buffer.from(data);
@@ -263,7 +209,7 @@ class ClientPatcher {
totalCount += sentryResult.count; totalCount += sentryResult.count;
} }
// 2. Patch main domain (hytale.com -> mainDomain) // 2. Patch main domain
console.log(` Patching domain: ${ORIGINAL_DOMAIN} -> ${strategy.mainDomain}`); console.log(` Patching domain: ${ORIGINAL_DOMAIN} -> ${strategy.mainDomain}`);
const domainResult = this.replaceBytes( const domainResult = this.replaceBytes(
result, result,
@@ -298,7 +244,7 @@ class ClientPatcher {
} }
/** /**
* Patch Discord invite URLs from .gg/hytale to .gg/MHkEjepMQ7 * Patch Discord invite URLs
*/ */
patchDiscordUrl(data) { patchDiscordUrl(data) {
let count = 0; let count = 0;
@@ -307,7 +253,6 @@ class ClientPatcher {
const oldUrl = '.gg/hytale'; const oldUrl = '.gg/hytale';
const newUrl = '.gg/MHkEjepMQ7'; const newUrl = '.gg/MHkEjepMQ7';
// Try length-prefixed format first
const lpResult = this.replaceBytes( const lpResult = this.replaceBytes(
result, result,
this.stringToLengthPrefixed(oldUrl), this.stringToLengthPrefixed(oldUrl),
@@ -323,7 +268,6 @@ class ClientPatcher {
const newUtf16 = this.stringToUtf16LE(newUrl); const newUtf16 = this.stringToUtf16LE(newUrl);
const positions = this.findAllOccurrences(result, oldUtf16); const positions = this.findAllOccurrences(result, oldUtf16);
for (const pos of positions) { for (const pos of positions) {
newUtf16.copy(result, pos); newUtf16.copy(result, pos);
count++; count++;
@@ -333,39 +277,66 @@ class ClientPatcher {
} }
/** /**
* Check if the client binary has already been patched * Check patch status of client binary
* Also verifies the binary actually contains the patched domain
*/ */
isPatchedAlready(clientPath) { getPatchStatus(clientPath) {
const newDomain = this.getNewDomain(); const newDomain = this.getNewDomain();
const patchFlagFile = clientPath + this.patchedFlag; const patchFlagFile = clientPath + this.patchedFlag;
// First check flag file
if (fs.existsSync(patchFlagFile)) { if (fs.existsSync(patchFlagFile)) {
try { try {
const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8')); const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8'));
if (flagData.targetDomain === newDomain) { const currentDomain = flagData.targetDomain;
// Verify the binary actually contains the patched domain
if (currentDomain === newDomain) {
const data = fs.readFileSync(clientPath); const data = fs.readFileSync(clientPath);
const strategy = this.getDomainStrategy(newDomain); const strategy = this.getDomainStrategy(newDomain);
const domainPattern = this.stringToLengthPrefixed(strategy.mainDomain); const domainPattern = this.stringToLengthPrefixed(strategy.mainDomain);
if (data.includes(domainPattern)) { if (data.includes(domainPattern)) {
return true; return { patched: true, currentDomain, needsRestore: false };
} else { } else {
console.log(' Flag exists but binary not patched (was updated?), re-patching...'); console.log(' Flag exists but binary not patched (was updated?), needs re-patching...');
return false; return { patched: false, currentDomain: null, needsRestore: false };
} }
} else {
console.log(` Currently patched for "${currentDomain}", need to change to "${newDomain}"`);
return { patched: false, currentDomain, needsRestore: true };
} }
} catch (e) { } catch (e) {
// Flag file corrupt or unreadable // Flag file corrupt
} }
} }
return { patched: false, currentDomain: null, needsRestore: false };
}
/**
* Check if client is already patched (backward compat)
*/
isPatchedAlready(clientPath) {
return this.getPatchStatus(clientPath).patched;
}
/**
* Restore client from backup
*/
restoreFromBackup(clientPath) {
const backupPath = clientPath + '.original';
if (fs.existsSync(backupPath)) {
console.log(' Restoring original binary from backup for re-patching...');
fs.copyFileSync(backupPath, clientPath);
const patchFlagFile = clientPath + this.patchedFlag;
if (fs.existsSync(patchFlagFile)) {
fs.unlinkSync(patchFlagFile);
}
return true;
}
console.warn(' No backup found to restore - will try patching anyway');
return false; return false;
} }
/** /**
* Mark the client as patched * Mark client as patched
*/ */
markAsPatched(clientPath) { markAsPatched(clientPath) {
const newDomain = this.getNewDomain(); const newDomain = this.getNewDomain();
@@ -378,29 +349,28 @@ class ClientPatcher {
patchMode: strategy.mode, patchMode: strategy.mode,
mainDomain: strategy.mainDomain, mainDomain: strategy.mainDomain,
subdomainPrefix: strategy.subdomainPrefix, subdomainPrefix: strategy.subdomainPrefix,
patcherVersion: '2.0.0', patcherVersion: '2.1.0',
verified: 'binary_contents' verified: 'binary_contents'
}; };
fs.writeFileSync(patchFlagFile, JSON.stringify(flagData, null, 2)); fs.writeFileSync(patchFlagFile, JSON.stringify(flagData, null, 2));
} }
/** /**
* Create a backup of the original client binary * Create backup of original client binary
*/ */
backupClient(clientPath) { backupClient(clientPath) {
const backupPath = clientPath + '.original'; const backupPath = clientPath + '.original';
try {
if (!fs.existsSync(backupPath)) { if (!fs.existsSync(backupPath)) {
console.log(` Creating backup at ${path.basename(backupPath)}`); console.log(` Creating backup at ${path.basename(backupPath)}`);
fs.copyFileSync(clientPath, backupPath); fs.copyFileSync(clientPath, backupPath);
return backupPath; return backupPath;
} }
// Check if current file differs from backup (might have been updated)
const currentSize = fs.statSync(clientPath).size; const currentSize = fs.statSync(clientPath).size;
const backupSize = fs.statSync(backupPath).size; const backupSize = fs.statSync(backupPath).size;
if (currentSize !== backupSize) { if (currentSize !== backupSize) {
// File was updated, create timestamped backup of old backup
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const oldBackupPath = `${clientPath}.original.${timestamp}`; const oldBackupPath = `${clientPath}.original.${timestamp}`;
console.log(` File updated, archiving old backup to ${path.basename(oldBackupPath)}`); console.log(` File updated, archiving old backup to ${path.basename(oldBackupPath)}`);
@@ -411,10 +381,14 @@ class ClientPatcher {
console.log(' Backup already exists'); console.log(' Backup already exists');
return backupPath; return backupPath;
} catch (e) {
console.error(` Failed to create backup: ${e.message}`);
return null;
}
} }
/** /**
* Restore the original client binary from backup * Restore original client binary
*/ */
restoreClient(clientPath) { restoreClient(clientPath) {
const backupPath = clientPath + '.original'; const backupPath = clientPath + '.original';
@@ -433,15 +407,12 @@ class ClientPatcher {
/** /**
* Patch the client binary to use the custom domain * Patch the client binary to use the custom domain
* @param {string} clientPath - Path to the HytaleClient binary
* @param {function} progressCallback - Optional callback for progress updates
* @returns {object} Result object with success status and details
*/ */
async patchClient(clientPath, progressCallback) { async patchClient(clientPath, progressCallback) {
const newDomain = this.getNewDomain(); const newDomain = this.getNewDomain();
const strategy = this.getDomainStrategy(newDomain); const strategy = this.getDomainStrategy(newDomain);
console.log('=== Client Patcher v2.0 ==='); console.log('=== Client Patcher v2.1 ===');
console.log(`Target: ${clientPath}`); console.log(`Target: ${clientPath}`);
console.log(`Domain: ${newDomain} (${newDomain.length} chars)`); console.log(`Domain: ${newDomain} (${newDomain.length} chars)`);
console.log(`Mode: ${strategy.mode}`); console.log(`Mode: ${strategy.mode}`);
@@ -456,32 +427,34 @@ class ClientPatcher {
return { success: false, error }; return { success: false, error };
} }
if (this.isPatchedAlready(clientPath)) { const patchStatus = this.getPatchStatus(clientPath);
if (patchStatus.patched) {
console.log(`Client already patched for ${newDomain}, skipping`); console.log(`Client already patched for ${newDomain}, skipping`);
if (progressCallback) { if (progressCallback) progressCallback('Client already patched', 100);
progressCallback('Client already patched', 100);
}
return { success: true, alreadyPatched: true, patchCount: 0 }; return { success: true, alreadyPatched: true, patchCount: 0 };
} }
if (progressCallback) { if (patchStatus.needsRestore) {
progressCallback('Preparing to patch client...', 10); if (progressCallback) progressCallback('Restoring original for domain change...', 5);
this.restoreFromBackup(clientPath);
} }
if (progressCallback) progressCallback('Preparing to patch client...', 10);
console.log('Creating backup...'); console.log('Creating backup...');
this.backupClient(clientPath); const backupResult = this.backupClient(clientPath);
if (!backupResult) {
if (progressCallback) { console.warn(' Could not create backup - proceeding without backup');
progressCallback('Reading client binary...', 20);
} }
if (progressCallback) progressCallback('Reading client binary...', 20);
console.log('Reading client binary...'); console.log('Reading client binary...');
const data = fs.readFileSync(clientPath); const data = fs.readFileSync(clientPath);
console.log(`Binary size: ${(data.length / 1024 / 1024).toFixed(2)} MB`); console.log(`Binary size: ${(data.length / 1024 / 1024).toFixed(2)} MB`);
if (progressCallback) { if (progressCallback) progressCallback('Patching domain references...', 50);
progressCallback('Patching domain references...', 50);
}
console.log('Applying domain patches (length-prefixed format)...'); console.log('Applying domain patches (length-prefixed format)...');
const { buffer: patchedData, count } = this.applyDomainPatches(data, newDomain); const { buffer: patchedData, count } = this.applyDomainPatches(data, newDomain);
@@ -492,7 +465,6 @@ class ClientPatcher {
if (count === 0 && discordCount === 0) { if (count === 0 && discordCount === 0) {
console.log('No occurrences found - trying legacy UTF-16LE format...'); console.log('No occurrences found - trying legacy UTF-16LE format...');
// Fallback to legacy patching for older binary formats
const legacyResult = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, strategy.mainDomain); const legacyResult = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, strategy.mainDomain);
if (legacyResult.count > 0) { if (legacyResult.count > 0) {
console.log(`Found ${legacyResult.count} occurrences with legacy format`); console.log(`Found ${legacyResult.count} occurrences with legacy format`);
@@ -505,18 +477,14 @@ class ClientPatcher {
return { success: true, patchCount: 0, warning: 'No occurrences found' }; return { success: true, patchCount: 0, warning: 'No occurrences found' };
} }
if (progressCallback) { if (progressCallback) progressCallback('Writing patched binary...', 80);
progressCallback('Writing patched binary...', 80);
}
console.log('Writing patched binary...'); console.log('Writing patched binary...');
fs.writeFileSync(clientPath, finalData); fs.writeFileSync(clientPath, finalData);
this.markAsPatched(clientPath); this.markAsPatched(clientPath);
if (progressCallback) { if (progressCallback) progressCallback('Patching complete', 100);
progressCallback('Patching complete', 100);
}
console.log(`Successfully patched ${count} domain occurrences and ${discordCount} Discord URLs`); console.log(`Successfully patched ${count} domain occurrences and ${discordCount} Discord URLs`);
console.log('=== Patching Complete ==='); console.log('=== Patching Complete ===');
@@ -525,16 +493,45 @@ class ClientPatcher {
} }
/** /**
* Patch the server JAR by downloading pre-patched version * Check if server JAR contains DualAuth classes (was patched)
* @param {string} serverPath - Path to the HytaleServer.jar
* @param {function} progressCallback - Optional callback for progress updates
* @param {string} javaPath - Path to Java executable (unused, kept for compatibility)
* @returns {object} Result object with success status and details
*/ */
async patchServer(serverPath, progressCallback, javaPath = null) { serverJarContainsDualAuth(serverPath) {
try {
const data = fs.readFileSync(serverPath);
// Check for DualAuthContext class signature in JAR
const signature = Buffer.from('DualAuthContext', 'utf8');
return data.includes(signature);
} catch (e) {
return false;
}
}
/**
* Validate downloaded file is not corrupt/partial
* Server JAR should be at least 50MB
*/
validateServerJarSize(serverPath) {
try {
const stats = fs.statSync(serverPath);
const minSize = 50 * 1024 * 1024; // 50MB minimum
if (stats.size < minSize) {
console.error(` Downloaded JAR too small: ${(stats.size / 1024 / 1024).toFixed(2)} MB (expected >50MB)`);
return false;
}
console.log(` Downloaded size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
return true;
} catch (e) {
return false;
}
}
/**
* Patch server JAR by downloading pre-patched version from CDN
*/
async patchServer(serverPath, progressCallback) {
const newDomain = this.getNewDomain(); const newDomain = this.getNewDomain();
console.log('=== Server Patcher TEMP SYSTEM NEED TO BE FIXED ==='); console.log('=== Server Patcher (Pre-patched Download) ===');
console.log(`Target: ${serverPath}`); console.log(`Target: ${serverPath}`);
console.log(`Domain: ${newDomain}`); console.log(`Domain: ${newDomain}`);
@@ -546,61 +543,82 @@ class ClientPatcher {
// Check if already patched // Check if already patched
const patchFlagFile = serverPath + '.dualauth_patched'; const patchFlagFile = serverPath + '.dualauth_patched';
let needsRestore = false;
if (fs.existsSync(patchFlagFile)) { if (fs.existsSync(patchFlagFile)) {
try { try {
const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8')); const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8'));
if (flagData.domain === newDomain) { if (flagData.domain === newDomain) {
// Verify JAR actually contains DualAuth classes (game may have auto-updated)
if (this.serverJarContainsDualAuth(serverPath)) {
console.log(`Server already patched for ${newDomain}, skipping`); console.log(`Server already patched for ${newDomain}, skipping`);
if (progressCallback) progressCallback('Server already patched', 100); if (progressCallback) progressCallback('Server already patched', 100);
return { success: true, alreadyPatched: true }; return { success: true, alreadyPatched: true };
} else {
console.log(' Flag exists but JAR not patched (was auto-updated?), will re-download...');
// Delete stale flag file
try { fs.unlinkSync(patchFlagFile); } catch (e) { /* ignore */ }
}
} else {
console.log(`Server patched for "${flagData.domain}", need to change to "${newDomain}"`);
needsRestore = true;
} }
} catch (e) { } catch (e) {
// Flag file corrupt, re-patch // Flag file corrupt, re-patch
console.log(' Flag file corrupt, will re-download');
try { fs.unlinkSync(patchFlagFile); } catch (e) { /* ignore */ }
}
}
// Restore backup if patched for different domain
if (needsRestore) {
const backupPath = serverPath + '.original';
if (fs.existsSync(backupPath)) {
if (progressCallback) progressCallback('Restoring original for domain change...', 5);
console.log('Restoring original JAR from backup for re-patching...');
fs.copyFileSync(backupPath, serverPath);
if (fs.existsSync(patchFlagFile)) {
fs.unlinkSync(patchFlagFile);
}
} else {
console.warn(' No backup found to restore - will download fresh patched JAR');
} }
} }
// Create backup // Create backup
if (progressCallback) progressCallback('Creating backup...', 10); if (progressCallback) progressCallback('Creating backup...', 10);
console.log('Creating backup...'); console.log('Creating backup...');
this.backupClient(serverPath); const backupResult = this.backupClient(serverPath);
if (!backupResult) {
console.warn(' Could not create backup - proceeding without backup');
}
// Only support standard domain (auth.sanasol.ws) via pre-patched download
if (newDomain !== 'auth.sanasol.ws' && newDomain !== 'sanasol.ws') {
console.error(`Domain "${newDomain}" requires DualAuthPatcher - only auth.sanasol.ws is supported via pre-patched download`);
return { success: false, error: `Unsupported domain: ${newDomain}. Only auth.sanasol.ws is supported.` };
}
// Download pre-patched JAR // Download pre-patched JAR
if (progressCallback) progressCallback('Downloading patched server JAR...', 30); if (progressCallback) progressCallback('Downloading patched server JAR...', 30);
console.log('Downloading pre-patched HytaleServer.jar'); console.log('Downloading pre-patched HytaleServer.jar...');
try { try {
const https = require('https'); const https = require('https');
const url = 'https://pub-027b315ece074e2e891002ca38384792.r2.dev/HytaleServer.jar'; const url = 'https://pub-027b315ece074e2e891002ca38384792.r2.dev/HytaleServer.jar';
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
https.get(url, (response) => { const handleResponse = (response) => {
if (response.statusCode === 302 || response.statusCode === 301) { if (response.statusCode === 302 || response.statusCode === 301) {
// Follow redirect https.get(response.headers.location, handleResponse).on('error', reject);
https.get(response.headers.location, (redirectResponse) => {
if (redirectResponse.statusCode !== 200) {
reject(new Error(`Failed to download: HTTP ${redirectResponse.statusCode}`));
return; return;
} }
const file = fs.createWriteStream(serverPath); if (response.statusCode !== 200) {
const totalSize = parseInt(redirectResponse.headers['content-length'], 10); reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
let downloaded = 0; return;
redirectResponse.on('data', (chunk) => {
downloaded += chunk.length;
if (progressCallback && totalSize) {
const percent = 30 + Math.floor((downloaded / totalSize) * 60);
progressCallback(`Downloading... ${(downloaded / 1024 / 1024).toFixed(2)} MB`, percent);
} }
});
redirectResponse.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', reject);
} else if (response.statusCode === 200) {
const file = fs.createWriteStream(serverPath); const file = fs.createWriteStream(serverPath);
const totalSize = parseInt(response.headers['content-length'], 10); const totalSize = parseInt(response.headers['content-length'], 10);
let downloaded = 0; let downloaded = 0;
@@ -618,10 +636,9 @@ class ClientPatcher {
file.close(); file.close();
resolve(); resolve();
}); });
} else { };
reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
} https.get(url, handleResponse).on('error', (err) => {
}).on('error', (err) => {
fs.unlink(serverPath, () => {}); fs.unlink(serverPath, () => {});
reject(err); reject(err);
}); });
@@ -629,12 +646,42 @@ class ClientPatcher {
console.log(' Download successful'); console.log(' Download successful');
// Verify downloaded JAR size and contents
if (progressCallback) progressCallback('Verifying downloaded JAR...', 95);
if (!this.validateServerJarSize(serverPath)) {
console.error('Downloaded JAR appears corrupt or incomplete');
// Restore backup on verification failure
const backupPath = serverPath + '.original';
if (fs.existsSync(backupPath)) {
fs.copyFileSync(backupPath, serverPath);
console.log('Restored backup after verification failure');
}
return { success: false, error: 'Downloaded JAR verification failed - file too small (corrupt/partial download)' };
}
if (!this.serverJarContainsDualAuth(serverPath)) {
console.error('Downloaded JAR does not contain DualAuth classes - invalid or corrupt download');
// Restore backup on verification failure
const backupPath = serverPath + '.original';
if (fs.existsSync(backupPath)) {
fs.copyFileSync(backupPath, serverPath);
console.log('Restored backup after verification failure');
}
return { success: false, error: 'Downloaded JAR verification failed - missing DualAuth classes' };
}
console.log(' Verification successful - DualAuth classes present');
// Mark as patched // Mark as patched
fs.writeFileSync(patchFlagFile, JSON.stringify({ fs.writeFileSync(patchFlagFile, JSON.stringify({
domain: newDomain, domain: newDomain,
patchedAt: new Date().toISOString(), patchedAt: new Date().toISOString(),
patcher: 'PrePatchedDownload', patcher: 'PrePatchedDownload',
source: 'https://pub-027b315ece074e2e891002ca38384792.r2.dev/HytaleServer.jar' source: 'https://download.sanasol.ws/download/HytaleServer.jar'
})); }));
if (progressCallback) progressCallback('Server patching complete', 100); if (progressCallback) progressCallback('Server patching complete', 100);
@@ -656,290 +703,7 @@ class ClientPatcher {
} }
/** /**
* Find Java executable - uses bundled JRE first (same as game uses) * Find client binary path based on platform
* Falls back to system Java if bundled not available
*/
findJava() {
// 1. Try bundled JRE first (comes with the game)
try {
const bundled = getBundledJavaPath(JRE_DIR);
if (bundled && fs.existsSync(bundled)) {
console.log(`Using bundled Java: ${bundled}`);
return bundled;
}
} catch (e) {
// Bundled not available
}
// 2. Try javaManager's getJavaExec (handles all fallbacks)
try {
const javaExec = getJavaExec(JRE_DIR);
if (javaExec && fs.existsSync(javaExec)) {
console.log(`Using Java from javaManager: ${javaExec}`);
return javaExec;
}
} catch (e) {
// Not available
}
// 3. Check JAVA_HOME
if (process.env.JAVA_HOME) {
const javaHome = process.env.JAVA_HOME;
const javaBin = path.join(javaHome, 'bin', process.platform === 'win32' ? 'java.exe' : 'java');
if (fs.existsSync(javaBin)) {
console.log(`Using Java from JAVA_HOME: ${javaBin}`);
return javaBin;
}
}
// 4. Try 'java' from PATH
try {
execSync('java -version 2>&1', { encoding: 'utf8' });
console.log('Using Java from PATH');
return 'java';
} catch (e) {
// Not in PATH
}
return null;
}
/**
* Download DualAuthPatcher from hytale-auth-server if not present
*/
async ensurePatcherDownloaded(patcherDir) {
const patcherJava = path.join(patcherDir, 'DualAuthPatcher.java');
const patcherUrl = 'https://raw.githubusercontent.com/sanasol/hytale-auth-server/master/patcher/DualAuthPatcher.java';
if (!fs.existsSync(patcherDir)) {
fs.mkdirSync(patcherDir, { recursive: true });
}
if (!fs.existsSync(patcherJava)) {
console.log('Downloading DualAuthPatcher from hytale-auth-server...');
try {
const https = require('https');
await new Promise((resolve, reject) => {
const file = fs.createWriteStream(patcherJava);
https.get(patcherUrl, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Follow redirect
https.get(response.headers.location, (redirectResponse) => {
redirectResponse.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', reject);
} else {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}
}).on('error', (err) => {
fs.unlink(patcherJava, () => {});
reject(err);
});
});
console.log(' Downloaded DualAuthPatcher.java');
} catch (e) {
console.error(` Failed to download DualAuthPatcher: ${e.message}`);
throw e;
}
}
}
/**
* Download ASM libraries if not present
*/
async ensureAsmLibraries(libDir) {
if (!fs.existsSync(libDir)) {
fs.mkdirSync(libDir, { recursive: true });
}
const libs = [
{ name: 'asm-9.6.jar', url: 'https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar' },
{ name: 'asm-tree-9.6.jar', url: 'https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar' },
{ name: 'asm-util-9.6.jar', url: 'https://repo1.maven.org/maven2/org/ow2/asm/asm-util/9.6/asm-util-9.6.jar' }
];
for (const lib of libs) {
const libPath = path.join(libDir, lib.name);
if (!fs.existsSync(libPath)) {
console.log(`Downloading ${lib.name}...`);
try {
const https = require('https');
await new Promise((resolve, reject) => {
const file = fs.createWriteStream(libPath);
https.get(lib.url, (response) => {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', (err) => {
fs.unlink(libPath, () => {});
reject(err);
});
});
console.log(` Downloaded ${lib.name}`);
} catch (e) {
console.error(` Failed to download ${lib.name}: ${e.message}`);
throw e;
}
}
}
}
/**
* Compile DualAuthPatcher if needed
*/
async compileDualAuthPatcher(java, patcherDir, libDir) {
const patcherClass = path.join(patcherDir, 'DualAuthPatcher.class');
const patcherJava = path.join(patcherDir, 'DualAuthPatcher.java');
// Check if already compiled and up to date
if (fs.existsSync(patcherClass)) {
const classTime = fs.statSync(patcherClass).mtime;
const javaTime = fs.statSync(patcherJava).mtime;
if (classTime > javaTime) {
console.log('DualAuthPatcher already compiled');
return { success: true };
}
}
console.log('Compiling DualAuthPatcher...');
const javac = java.replace(/java(\.exe)?$/, 'javac$1');
const classpath = [
path.join(libDir, 'asm-9.6.jar'),
path.join(libDir, 'asm-tree-9.6.jar'),
path.join(libDir, 'asm-util-9.6.jar')
].join(process.platform === 'win32' ? ';' : ':');
try {
// Fix PATH for packaged Electron apps on Windows
const execOptions = {
stdio: 'pipe',
cwd: patcherDir,
env: { ...process.env }
};
// Add system32 to PATH for Windows to find cmd.exe
if (process.platform === 'win32') {
const systemRoot = process.env.SystemRoot || 'C:\\WINDOWS';
const systemPath = `${systemRoot}\\system32;${systemRoot};${systemRoot}\\System32\\Wbem`;
execOptions.env.PATH = execOptions.env.PATH
? `${systemPath};${execOptions.env.PATH}`
: systemPath;
execOptions.shell = true;
}
execSync(`"${javac}" -cp "${classpath}" -d "${patcherDir}" "${patcherJava}"`, execOptions);
console.log(' Compilation successful');
return { success: true };
} catch (e) {
const error = `Failed to compile DualAuthPatcher: ${e.message}`;
console.error(error);
if (e.stderr) console.error(e.stderr.toString());
return { success: false, error };
}
}
/**
* Run DualAuthPatcher on the server JAR
*/
async runDualAuthPatcher(java, classpath, serverPath, domain) {
return new Promise((resolve) => {
const args = ['-cp', classpath, 'DualAuthPatcher', serverPath];
const env = { ...process.env, HYTALE_AUTH_DOMAIN: domain };
console.log(`Running: java ${args.join(' ')}`);
console.log(` HYTALE_AUTH_DOMAIN=${domain}`);
const proc = spawn(java, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => {
const str = data.toString();
stdout += str;
console.log(str.trim());
});
proc.stderr.on('data', (data) => {
const str = data.toString();
stderr += str;
console.error(str.trim());
});
proc.on('close', (code) => {
if (code === 0) {
resolve({ success: true, stdout });
} else {
resolve({ success: false, error: `Patcher exited with code ${code}: ${stderr}` });
}
});
proc.on('error', (err) => {
resolve({ success: false, error: `Failed to run patcher: ${err.message}` });
});
});
}
/**
* Legacy server patcher (simple domain replacement, no dual auth)
* Use patchServer() for full dual auth support
*/
async patchServerLegacy(serverPath, progressCallback) {
const newDomain = this.getNewDomain();
const strategy = this.getDomainStrategy(newDomain);
console.log('=== Legacy Server Patcher ===');
console.log(`Target: ${serverPath}`);
console.log(`Domain: ${newDomain} (${newDomain.length} chars)`);
if (!fs.existsSync(serverPath)) {
return { success: false, error: `Server JAR not found: ${serverPath}` };
}
if (progressCallback) progressCallback('Patching server...', 20);
console.log('Opening server JAR...');
const zip = new AdmZip(serverPath);
const entries = zip.getEntries();
let totalCount = 0;
const oldUtf8 = this.stringToUtf8(ORIGINAL_DOMAIN);
for (const entry of entries) {
const name = entry.entryName;
if (name.endsWith('.class') || name.endsWith('.properties') ||
name.endsWith('.json') || name.endsWith('.xml') || name.endsWith('.yml')) {
const data = entry.getData();
if (data.includes(oldUtf8)) {
const { buffer: patchedData, count } = this.findAndReplaceDomainUtf8(data, ORIGINAL_DOMAIN, strategy.mainDomain);
if (count > 0) {
zip.updateFile(entry.entryName, patchedData);
totalCount += count;
}
}
}
}
if (totalCount > 0) {
zip.writeZip(serverPath);
}
if (progressCallback) progressCallback('Complete', 100);
return { success: true, patchCount: totalCount };
}
/**
* Find the client binary path based on platform
*/ */
findClientPath(gameDir) { findClientPath(gameDir) {
const candidates = []; const candidates = [];
@@ -961,7 +725,9 @@ class ClientPatcher {
return null; return null;
} }
/**
* Find server JAR path
*/
findServerPath(gameDir) { findServerPath(gameDir) {
const candidates = [ const candidates = [
path.join(gameDir, 'Server', 'HytaleServer.jar'), path.join(gameDir, 'Server', 'HytaleServer.jar'),
@@ -978,9 +744,6 @@ class ClientPatcher {
/** /**
* Ensure both client and server are patched before launching * Ensure both client and server are patched before launching
* @param {string} gameDir - Path to the game directory
* @param {function} progressCallback - Optional callback for progress updates
* @param {string} javaPath - Optional path to Java executable for server patching
*/ */
async ensureClientPatched(gameDir, progressCallback, javaPath = null) { async ensureClientPatched(gameDir, progressCallback, javaPath = null) {
const results = { const results = {
@@ -991,9 +754,7 @@ class ClientPatcher {
const clientPath = this.findClientPath(gameDir); const clientPath = this.findClientPath(gameDir);
if (clientPath) { if (clientPath) {
if (progressCallback) { if (progressCallback) progressCallback('Patching client binary...', 10);
progressCallback('Patching client binary...', 10);
}
results.client = await this.patchClient(clientPath, (msg, pct) => { results.client = await this.patchClient(clientPath, (msg, pct) => {
if (progressCallback) { if (progressCallback) {
progressCallback(`Client: ${msg}`, pct ? pct / 2 : null); progressCallback(`Client: ${msg}`, pct ? pct / 2 : null);
@@ -1006,14 +767,12 @@ class ClientPatcher {
const serverPath = this.findServerPath(gameDir); const serverPath = this.findServerPath(gameDir);
if (serverPath) { if (serverPath) {
if (progressCallback) { if (progressCallback) progressCallback('Patching server JAR...', 50);
progressCallback('Patching server JAR...', 50);
}
results.server = await this.patchServer(serverPath, (msg, pct) => { results.server = await this.patchServer(serverPath, (msg, pct) => {
if (progressCallback) { if (progressCallback) {
progressCallback(`Server: ${msg}`, pct ? 50 + pct / 2 : null); progressCallback(`Server: ${msg}`, pct ? 50 + pct / 2 : null);
} }
}, javaPath); });
} else { } else {
console.warn('Could not find HytaleServer.jar'); console.warn('Could not find HytaleServer.jar');
results.server = { success: false, error: 'Server JAR not found' }; results.server = { success: false, error: 'Server JAR not found' };
@@ -1023,9 +782,7 @@ class ClientPatcher {
results.alreadyPatched = (results.client && results.client.alreadyPatched) && (results.server && results.server.alreadyPatched); results.alreadyPatched = (results.client && results.client.alreadyPatched) && (results.server && results.server.alreadyPatched);
results.patchCount = (results.client ? results.client.patchCount || 0 : 0) + (results.server ? results.server.patchCount || 0 : 0); results.patchCount = (results.client ? results.client.patchCount || 0 : 0) + (results.server ? results.server.patchCount || 0 : 0);
if (progressCallback) { if (progressCallback) progressCallback('Patching complete', 100);
progressCallback('Patching complete', 100);
}
return results; return results;
} }

64
main.js
View File

@@ -176,7 +176,8 @@ function createWindow() {
initDiscordRPC(); initDiscordRPC();
// Configure and initialize electron-updater // Configure and initialize electron-updater
autoUpdater.autoDownload = false; // Enable auto-download so updates start immediately when available
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('checking-for-update', () => { autoUpdater.on('checking-for-update', () => {
@@ -201,6 +202,20 @@ function createWindow() {
autoUpdater.on('error', (err) => { autoUpdater.on('error', (err) => {
console.error('Error in auto-updater:', err); console.error('Error in auto-updater:', err);
// Handle macOS code signing errors - requires manual download
if (mainWindow && !mainWindow.isDestroyed()) {
const isMacSigningError = process.platform === 'darwin' &&
(err.code === 'ERR_UPDATER_INVALID_SIGNATURE' ||
err.message.includes('signature') ||
err.message.includes('code sign'));
mainWindow.webContents.send('update-error', {
message: err.message,
isMacSigningError: isMacSigningError,
requiresManualDownload: isMacSigningError || process.platform === 'darwin'
});
}
}); });
autoUpdater.on('download-progress', (progressObj) => { autoUpdater.on('download-progress', (progressObj) => {
@@ -218,7 +233,10 @@ function createWindow() {
console.log('Update downloaded:', info.version); console.log('Update downloaded:', info.version);
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-downloaded', { mainWindow.webContents.send('update-downloaded', {
version: info.version version: info.version,
platform: process.platform,
// macOS auto-install often fails on unsigned apps
autoInstallSupported: process.platform !== 'darwin'
}); });
} }
}); });
@@ -859,6 +877,17 @@ ipcMain.handle('open-external', async (event, url) => {
} }
}); });
ipcMain.handle('open-download-page', async () => {
try {
// Open GitHub releases page for manual download
await shell.openExternal('https://github.com/amiayweb/Hytale-F2P/releases/latest');
return { success: true };
} catch (error) {
console.error('Failed to open download page:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('open-game-location', async () => { ipcMain.handle('open-game-location', async () => {
try { try {
const { getResolvedAppDir, loadVersionBranch } = require('./backend/launcher'); const { getResolvedAppDir, loadVersionBranch } = require('./backend/launcher');
@@ -1086,8 +1115,37 @@ ipcMain.handle('download-update', async () => {
} }
}); });
ipcMain.handle('install-update', () => { ipcMain.handle('install-update', async () => {
console.log('[AutoUpdater] Installing update...');
// On macOS, quitAndInstall often fails silently
// Use a more aggressive approach
if (process.platform === 'darwin') {
console.log('[AutoUpdater] macOS detected, using force quit approach');
// Give user feedback that something is happening
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-installing');
}
// Small delay to show the "Installing..." state
await new Promise(resolve => setTimeout(resolve, 500));
try {
autoUpdater.quitAndInstall(false, true); autoUpdater.quitAndInstall(false, true);
} catch (err) {
console.error('[AutoUpdater] quitAndInstall failed:', err);
// Force quit the app - the update should install on next launch
app.exit(0);
}
// If quitAndInstall didn't work, force exit after a delay
setTimeout(() => {
console.log('[AutoUpdater] Force exiting app...');
app.exit(0);
}, 2000);
} else {
autoUpdater.quitAndInstall(false, true);
}
}); });
ipcMain.handle('get-launcher-version', () => { ipcMain.handle('get-launcher-version', () => {

View File

@@ -24,7 +24,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
saveCloseLauncher: (enabled) => ipcRenderer.invoke('save-close-launcher', enabled), saveCloseLauncher: (enabled) => ipcRenderer.invoke('save-close-launcher', enabled),
loadCloseLauncher: () => ipcRenderer.invoke('load-close-launcher'), loadCloseLauncher: () => ipcRenderer.invoke('load-close-launcher'),
// Harwadre Acceleration // Hardware Acceleration
saveLauncherHardwareAcceleration: (enabled) => ipcRenderer.invoke('save-launcher-hw-accel', enabled), saveLauncherHardwareAcceleration: (enabled) => ipcRenderer.invoke('save-launcher-hw-accel', enabled),
loadLauncherHardwareAcceleration: () => ipcRenderer.invoke('load-launcher-hw-accel'), loadLauncherHardwareAcceleration: () => ipcRenderer.invoke('load-launcher-hw-accel'),
@@ -50,14 +50,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
selectModFiles: () => ipcRenderer.invoke('select-mod-files'), selectModFiles: () => ipcRenderer.invoke('select-mod-files'),
copyModFile: (sourcePath, modsPath) => ipcRenderer.invoke('copy-mod-file', sourcePath, modsPath), copyModFile: (sourcePath, modsPath) => ipcRenderer.invoke('copy-mod-file', sourcePath, modsPath),
onProgressUpdate: (callback) => { onProgressUpdate: (callback) => {
ipcRenderer.on('progress-update', (event, data) => { ipcRenderer.on('progress-update', (event, data) => callback(data));
// Ensure data includes retry state if available
if (data && typeof data === 'object') {
callback(data);
} else {
callback(data);
}
});
}, },
onProgressComplete: (callback) => { onProgressComplete: (callback) => {
ipcRenderer.on('progress-complete', () => callback()); ipcRenderer.on('progress-complete', () => callback());
@@ -69,7 +62,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('installation-end', () => callback()); ipcRenderer.on('installation-end', () => callback());
}, },
getUserId: () => ipcRenderer.invoke('get-user-id'), getUserId: () => ipcRenderer.invoke('get-user-id'),
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
openDownloadPage: () => ipcRenderer.invoke('open-download-page'), openDownloadPage: () => ipcRenderer.invoke('open-download-page'),
getUpdateInfo: () => ipcRenderer.invoke('get-update-info'), getUpdateInfo: () => ipcRenderer.invoke('get-update-info'),
onUpdatePopup: (callback) => { onUpdatePopup: (callback) => {
@@ -126,6 +118,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadUpdate: () => ipcRenderer.invoke('download-update'), downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'), installUpdate: () => ipcRenderer.invoke('install-update'),
quitAndInstallUpdate: () => ipcRenderer.invoke('install-update'), // Alias for update.js compatibility
getLauncherVersion: () => ipcRenderer.invoke('get-launcher-version'), getLauncherVersion: () => ipcRenderer.invoke('get-launcher-version'),
onUpdateAvailable: (callback) => { onUpdateAvailable: (callback) => {
ipcRenderer.on('update-available', (event, data) => callback(data)); ipcRenderer.on('update-available', (event, data) => callback(data));