mirror of
https://git.sanhost.net/sanasol/hytale-f2p
synced 2026-04-18 12:12:23 -04:00
Compare commits
1 Commits
v2.0.4-aut
...
v2.0.1-mac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ec6d3fd95 |
17
BUILD.md
17
BUILD.md
@@ -36,4 +36,19 @@ npm run build:mac
|
|||||||
npm run build:all
|
npm run build:all
|
||||||
```
|
```
|
||||||
|
|
||||||
Built executables will be in the `dist/` directory
|
## Output
|
||||||
|
|
||||||
|
Built executables will be in the `dist/` directory:
|
||||||
|
|
||||||
|
- **Windows**: `Hytale F2P Launcher Setup.exe` (NSIS installer) and `Hytale F2P Launcher.exe` (portable)
|
||||||
|
- **Linux**: `Hytale F2P Launcher.AppImage` and `Hytale F2P Launcher.deb`
|
||||||
|
- **macOS**: `Hytale F2P Launcher.dmg` and `Hytale F2P Launcher.zip`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Icons need to be placed in `build/` directory:
|
||||||
|
- `icon.ico` for Windows
|
||||||
|
- `icon.png` for Linux
|
||||||
|
- `icon.icns` for macOS
|
||||||
|
- To build for macOS on non-Mac systems, you'll need to run it on a Mac or use a CI/CD service
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Download server file:**
|
**Download server file:**
|
||||||
```
|
```
|
||||||
https://files.hytalef2p.com/server
|
http://3.10.208.30:3002/server
|
||||||
```
|
```
|
||||||
|
|
||||||
**Replace the file here:**
|
**Replace the file here:**
|
||||||
|
|||||||
@@ -2,35 +2,6 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
|
||||||
// Default auth domain - can be overridden by env var or config
|
|
||||||
const DEFAULT_AUTH_DOMAIN = 'sanasol.ws';
|
|
||||||
|
|
||||||
// Get auth domain from env, config, or default
|
|
||||||
function getAuthDomain() {
|
|
||||||
// First check environment variable
|
|
||||||
if (process.env.HYTALE_AUTH_DOMAIN) {
|
|
||||||
return process.env.HYTALE_AUTH_DOMAIN;
|
|
||||||
}
|
|
||||||
// Then check config file
|
|
||||||
const config = loadConfig();
|
|
||||||
if (config.authDomain) {
|
|
||||||
return config.authDomain;
|
|
||||||
}
|
|
||||||
// Fall back to default
|
|
||||||
return DEFAULT_AUTH_DOMAIN;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get full auth server URL
|
|
||||||
function getAuthServerUrl() {
|
|
||||||
const domain = getAuthDomain();
|
|
||||||
return `https://sessions.${domain}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save auth domain to config
|
|
||||||
function saveAuthDomain(domain) {
|
|
||||||
saveConfig({ authDomain: domain || DEFAULT_AUTH_DOMAIN });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAppDir() {
|
function getAppDir() {
|
||||||
const home = os.homedir();
|
const home = os.homedir();
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
@@ -188,10 +159,5 @@ module.exports = {
|
|||||||
loadModsFromConfig,
|
loadModsFromConfig,
|
||||||
isFirstLaunch,
|
isFirstLaunch,
|
||||||
markAsLaunched,
|
markAsLaunched,
|
||||||
CONFIG_FILE,
|
CONFIG_FILE
|
||||||
// Auth domain config
|
|
||||||
DEFAULT_AUTH_DOMAIN,
|
|
||||||
getAuthDomain,
|
|
||||||
getAuthServerUrl,
|
|
||||||
saveAuthDomain
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,105 +1,17 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
|
||||||
const { exec } = require('child_process');
|
const { exec } = require('child_process');
|
||||||
const { promisify } = require('util');
|
const { promisify } = require('util');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
const { getResolvedAppDir, findClientPath } = require('../core/paths');
|
const { getResolvedAppDir, findClientPath } = require('../core/paths');
|
||||||
const { setupWaylandEnvironment } = require('../utils/platformUtils');
|
const { setupWaylandEnvironment } = require('../utils/platformUtils');
|
||||||
const { saveUsername, saveInstallPath, loadJavaPath, getUuidForUser, getAuthServerUrl, getAuthDomain } = require('../core/config');
|
const { saveUsername, saveInstallPath, loadJavaPath, getUuidForUser } = require('../core/config');
|
||||||
const { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA_EXECUTABLE } = require('./javaManager');
|
const { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA_EXECUTABLE } = require('./javaManager');
|
||||||
const { getInstalledClientVersion, getLatestClientVersion } = require('../services/versionManager');
|
const { getInstalledClientVersion, getLatestClientVersion } = require('../services/versionManager');
|
||||||
const { updateGameFiles } = require('./gameManager');
|
const { updateGameFiles } = require('./gameManager');
|
||||||
|
|
||||||
// Client patcher for custom auth server (sanasol.ws)
|
|
||||||
let clientPatcher = null;
|
|
||||||
try {
|
|
||||||
clientPatcher = require('../utils/clientPatcher');
|
|
||||||
} catch (err) {
|
|
||||||
console.log('[Launcher] Client patcher not available:', err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
// Fetch tokens from the auth server (properly signed with server's Ed25519 key)
|
|
||||||
async function fetchAuthTokens(uuid, name) {
|
|
||||||
const authServerUrl = getAuthServerUrl();
|
|
||||||
try {
|
|
||||||
console.log(`Fetching auth tokens from ${authServerUrl}/game-session/child`);
|
|
||||||
|
|
||||||
const response = 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 (!response.ok) {
|
|
||||||
throw new Error(`Auth server returned ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('Auth tokens received from server');
|
|
||||||
|
|
||||||
return {
|
|
||||||
identityToken: data.IdentityToken || data.identityToken,
|
|
||||||
sessionToken: data.SessionToken || data.sessionToken
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch auth tokens:', error.message);
|
|
||||||
// Fallback to local generation if server unavailable
|
|
||||||
return generateLocalTokens(uuid, name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Generate tokens locally (won't pass signature validation but allows offline testing)
|
|
||||||
function generateLocalTokens(uuid, name) {
|
|
||||||
console.log('Using locally generated tokens (fallback mode)');
|
|
||||||
const authServerUrl = getAuthServerUrl();
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const exp = now + 36000;
|
|
||||||
|
|
||||||
const header = Buffer.from(JSON.stringify({
|
|
||||||
alg: 'EdDSA',
|
|
||||||
kid: '2025-10-01',
|
|
||||||
typ: 'JWT'
|
|
||||||
})).toString('base64url');
|
|
||||||
|
|
||||||
const identityPayload = Buffer.from(JSON.stringify({
|
|
||||||
sub: uuid,
|
|
||||||
name: name,
|
|
||||||
username: name,
|
|
||||||
entitlements: ['game.base'],
|
|
||||||
scope: 'hytale:server hytale:client',
|
|
||||||
iat: now,
|
|
||||||
exp: exp,
|
|
||||||
iss: authServerUrl,
|
|
||||||
jti: uuidv4()
|
|
||||||
})).toString('base64url');
|
|
||||||
|
|
||||||
const sessionPayload = Buffer.from(JSON.stringify({
|
|
||||||
sub: uuid,
|
|
||||||
scope: 'hytale:server',
|
|
||||||
iat: now,
|
|
||||||
exp: exp,
|
|
||||||
iss: authServerUrl,
|
|
||||||
jti: uuidv4()
|
|
||||||
})).toString('base64url');
|
|
||||||
|
|
||||||
const signature = crypto.randomBytes(64).toString('base64url');
|
|
||||||
|
|
||||||
return {
|
|
||||||
identityToken: `${header}.${identityPayload}.${signature}`,
|
|
||||||
sessionToken: `${header}.${sessionPayload}.${signature}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launchGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride) {
|
async function launchGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride) {
|
||||||
const customAppDir = getResolvedAppDir(installPathOverride);
|
const customAppDir = getResolvedAppDir(installPathOverride);
|
||||||
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
|
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
|
||||||
@@ -141,51 +53,6 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uuid = getUuidForUser(playerName);
|
|
||||||
|
|
||||||
// Fetch tokens from auth server
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Fetching authentication tokens...', null, null, null, null);
|
|
||||||
}
|
|
||||||
const { identityToken, sessionToken } = await fetchAuthTokens(uuid, playerName);
|
|
||||||
|
|
||||||
// Patch client and server binaries to use custom auth server (BEFORE signing on macOS)
|
|
||||||
const authDomain = getAuthDomain();
|
|
||||||
if (clientPatcher) {
|
|
||||||
try {
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Patching game for custom server...', null, null, null, null);
|
|
||||||
}
|
|
||||||
console.log(`Patching game binaries for ${authDomain}...`);
|
|
||||||
|
|
||||||
const patchResult = await clientPatcher.ensureClientPatched(gameLatest, (msg, percent) => {
|
|
||||||
console.log(`[Patcher] ${msg}`);
|
|
||||||
if (progressCallback && msg) {
|
|
||||||
progressCallback(msg, percent, null, null, null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (patchResult.success) {
|
|
||||||
if (patchResult.alreadyPatched) {
|
|
||||||
console.log(`Game already patched for ${authDomain}`);
|
|
||||||
} else {
|
|
||||||
console.log(`Game patched successfully (${patchResult.patchCount} total occurrences)`);
|
|
||||||
if (patchResult.client) {
|
|
||||||
console.log(` Client: ${patchResult.client.patchCount || 0} occurrences`);
|
|
||||||
}
|
|
||||||
if (patchResult.server) {
|
|
||||||
console.log(` Server: ${patchResult.server.patchCount || 0} occurrences`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('Game patching failed:', patchResult.error);
|
|
||||||
}
|
|
||||||
} catch (patchError) {
|
|
||||||
console.warn('Game patching failed (game may not connect to custom server):', patchError.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// macOS: Sign binaries AFTER patching so the patched binaries have valid signatures
|
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
try {
|
try {
|
||||||
const appBundle = path.join(gameLatest, 'Client', 'Hytale.app');
|
const appBundle = path.join(gameLatest, 'Client', 'Hytale.app');
|
||||||
@@ -199,10 +66,10 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
|
|||||||
|
|
||||||
if (fs.existsSync(appBundle)) {
|
if (fs.existsSync(appBundle)) {
|
||||||
await signPath(appBundle, true);
|
await signPath(appBundle, true);
|
||||||
console.log('Signed macOS app bundle (after patching)');
|
console.log('Signed macOS app bundle');
|
||||||
} else {
|
} else {
|
||||||
await signPath(path.dirname(clientPath), true);
|
await signPath(path.dirname(clientPath), true);
|
||||||
console.log('Signed macOS client binary (after patching)');
|
console.log('Signed macOS client binary');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (javaBin && fs.existsSync(javaBin)) {
|
if (javaBin && fs.existsSync(javaBin)) {
|
||||||
@@ -218,7 +85,7 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
|
|||||||
if (fs.existsSync(serverDir)) {
|
if (fs.existsSync(serverDir)) {
|
||||||
await execAsync(`xattr -cr "${serverDir}"`).catch(() => {});
|
await execAsync(`xattr -cr "${serverDir}"`).catch(() => {});
|
||||||
await execAsync(`find "${serverDir}" -type f -perm +111 -exec codesign --force --sign - {} \\;`).catch(() => {});
|
await execAsync(`find "${serverDir}" -type f -perm +111 -exec codesign --force --sign - {} \\;`).catch(() => {});
|
||||||
console.log('Signed server binaries (after patching)');
|
console.log('Signed server binaries');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (javaBin && fs.existsSync(javaBin)) {
|
if (javaBin && fs.existsSync(javaBin)) {
|
||||||
@@ -246,14 +113,13 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uuid = getUuidForUser(playerName);
|
||||||
const args = [
|
const args = [
|
||||||
'--app-dir', gameLatest,
|
'--app-dir', gameLatest,
|
||||||
'--java-exec', javaBin,
|
'--java-exec', javaBin,
|
||||||
'--auth-mode', 'authenticated',
|
'--auth-mode', 'offline',
|
||||||
'--uuid', uuid,
|
'--uuid', uuid,
|
||||||
'--name', playerName,
|
'--name', playerName,
|
||||||
'--identity-token', identityToken,
|
|
||||||
'--session-token', sessionToken,
|
|
||||||
'--user-dir', userDataDir
|
'--user-dir', userDataDir
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,511 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const AdmZip = require('adm-zip');
|
|
||||||
|
|
||||||
// Domain configuration
|
|
||||||
const ORIGINAL_DOMAIN = 'hytale.com';
|
|
||||||
|
|
||||||
// Get target domain from config or environment
|
|
||||||
function getTargetDomain() {
|
|
||||||
// Check environment variable first
|
|
||||||
if (process.env.HYTALE_AUTH_DOMAIN) {
|
|
||||||
return process.env.HYTALE_AUTH_DOMAIN;
|
|
||||||
}
|
|
||||||
// Try to load from config
|
|
||||||
try {
|
|
||||||
const { getAuthDomain } = require('../core/config');
|
|
||||||
return getAuthDomain();
|
|
||||||
} catch (e) {
|
|
||||||
// Config not available, use default
|
|
||||||
return 'sanasol.ws';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default domain - must be exactly 10 characters (same as hytale.com)
|
|
||||||
const DEFAULT_NEW_DOMAIN = 'sanasol.ws';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Patches HytaleClient and HytaleServer binaries to replace hytale.com with custom domain
|
|
||||||
* This allows the game to connect to a custom authentication server
|
|
||||||
*/
|
|
||||||
class ClientPatcher {
|
|
||||||
constructor() {
|
|
||||||
this.patchedFlag = '.patched_custom';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the target domain for patching
|
|
||||||
*/
|
|
||||||
getNewDomain() {
|
|
||||||
const domain = getTargetDomain();
|
|
||||||
// Validate domain length matches original
|
|
||||||
if (domain.length !== ORIGINAL_DOMAIN.length) {
|
|
||||||
console.warn(`Warning: Domain "${domain}" length (${domain.length}) doesn't match original "${ORIGINAL_DOMAIN}" (${ORIGINAL_DOMAIN.length})`);
|
|
||||||
console.warn(`Using default domain: ${DEFAULT_NEW_DOMAIN}`);
|
|
||||||
return DEFAULT_NEW_DOMAIN;
|
|
||||||
}
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a string to UTF-16LE bytes (how .NET stores strings)
|
|
||||||
*/
|
|
||||||
stringToUtf16LE(str) {
|
|
||||||
const buf = Buffer.alloc(str.length * 2);
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
buf.writeUInt16LE(str.charCodeAt(i), i * 2);
|
|
||||||
}
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
findAllOccurrences(buffer, pattern) {
|
|
||||||
const positions = [];
|
|
||||||
let pos = 0;
|
|
||||||
while (pos < buffer.length) {
|
|
||||||
const index = buffer.indexOf(pattern, pos);
|
|
||||||
if (index === -1) break;
|
|
||||||
positions.push(index);
|
|
||||||
pos = index + 1;
|
|
||||||
}
|
|
||||||
return positions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UTF-8 domain replacement for Java JAR files.
|
|
||||||
* 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);
|
|
||||||
|
|
||||||
// Find all occurrences of the domain
|
|
||||||
const positions = this.findAllOccurrences(result, oldUtf8);
|
|
||||||
|
|
||||||
for (const pos of positions) {
|
|
||||||
// Replace the domain
|
|
||||||
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) {
|
|
||||||
let count = 0;
|
|
||||||
const result = Buffer.from(data);
|
|
||||||
|
|
||||||
// Get UTF-16LE bytes without the last character
|
|
||||||
const oldUtf16NoLast = this.stringToUtf16LE(oldDomain.slice(0, -1));
|
|
||||||
const newUtf16NoLast = this.stringToUtf16LE(newDomain.slice(0, -1));
|
|
||||||
const oldLastChar = this.stringToUtf16LE(oldDomain.slice(-1));
|
|
||||||
const newLastChar = this.stringToUtf16LE(newDomain.slice(-1));
|
|
||||||
|
|
||||||
// ASCII code of last characters
|
|
||||||
const oldLastCharByte = oldDomain.charCodeAt(oldDomain.length - 1);
|
|
||||||
const newLastCharByte = newDomain.charCodeAt(newDomain.length - 1);
|
|
||||||
|
|
||||||
// Find all occurrences of the domain without the last character
|
|
||||||
const positions = this.findAllOccurrences(result, oldUtf16NoLast);
|
|
||||||
|
|
||||||
for (const pos of positions) {
|
|
||||||
// Check if we have the last character following
|
|
||||||
const lastCharPos = pos + oldUtf16NoLast.length;
|
|
||||||
if (lastCharPos + 1 > result.length) continue;
|
|
||||||
|
|
||||||
// Read the byte at last char position
|
|
||||||
const lastCharFirstByte = result[lastCharPos];
|
|
||||||
|
|
||||||
// Check if first byte matches the last character of old domain
|
|
||||||
if (lastCharFirstByte === oldLastCharByte) {
|
|
||||||
// Replace all but last character
|
|
||||||
newUtf16NoLast.copy(result, pos);
|
|
||||||
|
|
||||||
// Replace just the first byte of the last character (preserve metadata byte if any)
|
|
||||||
result[lastCharPos] = newLastCharByte;
|
|
||||||
|
|
||||||
// If there's a proper null byte (standard UTF-16LE), also check/preserve it
|
|
||||||
if (lastCharPos + 1 < result.length) {
|
|
||||||
const secondByte = result[lastCharPos + 1];
|
|
||||||
// Log what type of occurrence this is
|
|
||||||
if (secondByte === 0x00) {
|
|
||||||
console.log(` Patched UTF-16LE occurrence at offset 0x${pos.toString(16)}`);
|
|
||||||
} else {
|
|
||||||
console.log(` Patched length-prefixed occurrence at offset 0x${pos.toString(16)} (metadata: 0x${secondByte.toString(16)})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { buffer: result, count };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the client binary has already been patched
|
|
||||||
*/
|
|
||||||
isPatchedAlready(clientPath) {
|
|
||||||
const newDomain = this.getNewDomain();
|
|
||||||
const patchFlagFile = clientPath + this.patchedFlag;
|
|
||||||
if (fs.existsSync(patchFlagFile)) {
|
|
||||||
try {
|
|
||||||
const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8'));
|
|
||||||
// Check if patched with same target domain
|
|
||||||
if (flagData.targetDomain === newDomain) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Flag file corrupted, will re-patch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark the client as patched
|
|
||||||
*/
|
|
||||||
markAsPatched(clientPath) {
|
|
||||||
const newDomain = this.getNewDomain();
|
|
||||||
const patchFlagFile = clientPath + this.patchedFlag;
|
|
||||||
const flagData = {
|
|
||||||
patchedAt: new Date().toISOString(),
|
|
||||||
originalDomain: ORIGINAL_DOMAIN,
|
|
||||||
targetDomain: newDomain,
|
|
||||||
patcherVersion: '1.0.0'
|
|
||||||
};
|
|
||||||
fs.writeFileSync(patchFlagFile, JSON.stringify(flagData, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a backup of the original client binary
|
|
||||||
*/
|
|
||||||
backupClient(clientPath) {
|
|
||||||
const backupPath = clientPath + '.original';
|
|
||||||
if (!fs.existsSync(backupPath)) {
|
|
||||||
console.log(` Creating backup at ${path.basename(backupPath)}`);
|
|
||||||
fs.copyFileSync(clientPath, backupPath);
|
|
||||||
return backupPath;
|
|
||||||
}
|
|
||||||
console.log(' Backup already exists');
|
|
||||||
return backupPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore the original client binary from backup
|
|
||||||
*/
|
|
||||||
restoreClient(clientPath) {
|
|
||||||
const backupPath = clientPath + '.original';
|
|
||||||
if (fs.existsSync(backupPath)) {
|
|
||||||
fs.copyFileSync(backupPath, clientPath);
|
|
||||||
const patchFlagFile = clientPath + this.patchedFlag;
|
|
||||||
if (fs.existsSync(patchFlagFile)) {
|
|
||||||
fs.unlinkSync(patchFlagFile);
|
|
||||||
}
|
|
||||||
console.log('Client restored from backup');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
console.log('No backup found to restore');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
|
||||||
const newDomain = this.getNewDomain();
|
|
||||||
console.log('=== Client Patcher ===');
|
|
||||||
console.log(`Target: ${clientPath}`);
|
|
||||||
console.log(`Replacing: ${ORIGINAL_DOMAIN} -> ${newDomain}`);
|
|
||||||
|
|
||||||
// Check if file exists
|
|
||||||
if (!fs.existsSync(clientPath)) {
|
|
||||||
const error = `Client binary not found: ${clientPath}`;
|
|
||||||
console.error(error);
|
|
||||||
return { success: false, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already patched
|
|
||||||
if (this.isPatchedAlready(clientPath)) {
|
|
||||||
console.log(`Client already patched for ${newDomain}, skipping`);
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Client already patched', 100);
|
|
||||||
}
|
|
||||||
return { success: true, alreadyPatched: true, patchCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Preparing to patch client...', 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create backup
|
|
||||||
console.log('Creating backup...');
|
|
||||||
this.backupClient(clientPath);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Reading client binary...', 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the binary
|
|
||||||
console.log('Reading client binary...');
|
|
||||||
const data = fs.readFileSync(clientPath);
|
|
||||||
console.log(`Binary size: ${(data.length / 1024 / 1024).toFixed(2)} MB`);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Patching domain references...', 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform the domain replacement
|
|
||||||
console.log('Patching domain references...');
|
|
||||||
const { buffer: patchedData, count } = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, newDomain);
|
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
console.log('No occurrences of hytale.com found - binary may already be modified or has different format');
|
|
||||||
return { success: true, patchCount: 0, warning: 'No domain occurrences found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Writing patched binary...', 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the patched binary
|
|
||||||
console.log('Writing patched binary...');
|
|
||||||
fs.writeFileSync(clientPath, patchedData);
|
|
||||||
|
|
||||||
// Mark as patched
|
|
||||||
this.markAsPatched(clientPath);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Patching complete', 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Successfully patched ${count} occurrences`);
|
|
||||||
console.log('=== Patching Complete ===');
|
|
||||||
|
|
||||||
return { success: true, patchCount: count };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Patch the server JAR to use the custom domain
|
|
||||||
* JAR files are ZIP archives, so we need to extract, patch class files, and repackage
|
|
||||||
* @param {string} serverPath - Path to the HytaleServer.jar
|
|
||||||
* @param {function} progressCallback - Optional callback for progress updates
|
|
||||||
* @returns {object} Result object with success status and details
|
|
||||||
*/
|
|
||||||
async patchServer(serverPath, progressCallback) {
|
|
||||||
const newDomain = this.getNewDomain();
|
|
||||||
console.log('=== Server Patcher ===');
|
|
||||||
console.log(`Target: ${serverPath}`);
|
|
||||||
console.log(`Replacing: ${ORIGINAL_DOMAIN} -> ${newDomain}`);
|
|
||||||
|
|
||||||
// Check if file exists
|
|
||||||
if (!fs.existsSync(serverPath)) {
|
|
||||||
const error = `Server JAR not found: ${serverPath}`;
|
|
||||||
console.error(error);
|
|
||||||
return { success: false, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already patched
|
|
||||||
if (this.isPatchedAlready(serverPath)) {
|
|
||||||
console.log(`Server already patched for ${newDomain}, skipping`);
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Server already patched', 100);
|
|
||||||
}
|
|
||||||
return { success: true, alreadyPatched: true, patchCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Preparing to patch server...', 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create backup
|
|
||||||
console.log('Creating backup...');
|
|
||||||
this.backupClient(serverPath);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Extracting server JAR...', 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the JAR file as a ZIP
|
|
||||||
console.log('Opening server JAR...');
|
|
||||||
const zip = new AdmZip(serverPath);
|
|
||||||
const entries = zip.getEntries();
|
|
||||||
console.log(`JAR contains ${entries.length} entries`);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Patching class files...', 40);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch each entry that might contain domain strings
|
|
||||||
let totalCount = 0;
|
|
||||||
const oldUtf8 = this.stringToUtf8(ORIGINAL_DOMAIN);
|
|
||||||
const newUtf8 = this.stringToUtf8(newDomain);
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
// Only patch class files and certain resource files
|
|
||||||
const name = entry.entryName;
|
|
||||||
if (name.endsWith('.class') || name.endsWith('.properties') ||
|
|
||||||
name.endsWith('.json') || name.endsWith('.xml') || name.endsWith('.yml')) {
|
|
||||||
|
|
||||||
const data = entry.getData();
|
|
||||||
|
|
||||||
// Check if this entry contains the domain
|
|
||||||
if (data.includes(oldUtf8)) {
|
|
||||||
const { buffer: patchedData, count } = this.findAndReplaceDomainUtf8(data, ORIGINAL_DOMAIN, newDomain);
|
|
||||||
if (count > 0) {
|
|
||||||
zip.updateFile(entry.entryName, patchedData);
|
|
||||||
console.log(` Patched ${count} occurrences in ${name}`);
|
|
||||||
totalCount += count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalCount === 0) {
|
|
||||||
console.log('No occurrences of hytale.com found in server JAR entries');
|
|
||||||
return { success: true, patchCount: 0, warning: 'No domain occurrences found in JAR' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Writing patched JAR...', 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the patched JAR
|
|
||||||
console.log('Writing patched JAR...');
|
|
||||||
zip.writeZip(serverPath);
|
|
||||||
|
|
||||||
// Mark as patched
|
|
||||||
this.markAsPatched(serverPath);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Server patching complete', 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Successfully patched ${totalCount} occurrences in server`);
|
|
||||||
console.log('=== Server Patching Complete ===');
|
|
||||||
|
|
||||||
return { success: true, patchCount: totalCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the client binary path based on platform
|
|
||||||
*/
|
|
||||||
findClientPath(gameDir) {
|
|
||||||
const candidates = [];
|
|
||||||
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
// macOS: Check both app bundle and direct binary
|
|
||||||
candidates.push(path.join(gameDir, 'Client', 'Hytale.app', 'Contents', 'MacOS', 'HytaleClient'));
|
|
||||||
candidates.push(path.join(gameDir, 'Client', 'HytaleClient'));
|
|
||||||
} else if (process.platform === 'win32') {
|
|
||||||
candidates.push(path.join(gameDir, 'Client', 'HytaleClient.exe'));
|
|
||||||
} else {
|
|
||||||
candidates.push(path.join(gameDir, 'Client', 'HytaleClient'));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (fs.existsSync(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the server JAR path
|
|
||||||
*/
|
|
||||||
findServerPath(gameDir) {
|
|
||||||
const candidates = [
|
|
||||||
path.join(gameDir, 'Server', 'HytaleServer.jar'),
|
|
||||||
path.join(gameDir, 'Server', 'server.jar')
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (fs.existsSync(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
async ensureClientPatched(gameDir, progressCallback) {
|
|
||||||
const results = {
|
|
||||||
client: null,
|
|
||||||
server: null,
|
|
||||||
success: true
|
|
||||||
};
|
|
||||||
|
|
||||||
// Patch client
|
|
||||||
const clientPath = this.findClientPath(gameDir);
|
|
||||||
if (clientPath) {
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Patching client binary...', 10);
|
|
||||||
}
|
|
||||||
results.client = await this.patchClient(clientPath, (msg, pct) => {
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback(`Client: ${msg}`, pct ? pct / 2 : null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.warn('Could not find HytaleClient binary');
|
|
||||||
results.client = { success: false, error: 'Client binary not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch server
|
|
||||||
const serverPath = this.findServerPath(gameDir);
|
|
||||||
if (serverPath) {
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Patching server JAR...', 50);
|
|
||||||
}
|
|
||||||
results.server = await this.patchServer(serverPath, (msg, pct) => {
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback(`Server: ${msg}`, pct ? 50 + pct / 2 : null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.warn('Could not find HytaleServer.jar');
|
|
||||||
results.server = { success: false, error: 'Server JAR not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate overall success
|
|
||||||
results.success = (results.client && results.client.success) || (results.server && results.server.success);
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Patching complete', 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
module.exports = new ClientPatcher();
|
|
||||||
Reference in New Issue
Block a user