Files
hytale-f2p-mirror/backend/utils/clientPatcher.js
AMIAY e5b44341f1 Develop (#282)
* fix: resolve cross-platform EPERM permissions errors

modManager.js:
- Switch from hardcoded 'junction' to dynamic symlink type based on OS (fixing Linux EPERM).
- Add retry logic for directory removal to handle file locking race conditions.
- Improve broken symlink detection during profile sync.

gameManager.js:
- Implement retry loop (3 attempts) for game directory removal in updateGameFiles to prevent EBUSY/EPERM errors on Windows.

paths.js:
- Prevent fs.mkdirSync failure in getModsPath by pre-checking for broken symbolic links.

* fix: missing pacman builds

* prepare release for 2.1.1

minor fix for EPERM error permission

* prepare release 2.1.1

minor fix EPERM permission error

* prepare release 2.1.1

* Update README.md Windows Prequisites for ARM64 builds

* fix: remove broken symlink after detected

* fix: add pathexists for paths.js to check symlink

* fix: isbrokenlink should be true to remove the symlink

* add arch package .pkg.tar.zst for release

* fix: release workflow for build-arch and build-linux

* build-arch job now only build arch .pkg.tar.zst package instead of the whole generic linux.
* build-linux job now exclude .pacman package since its deprecated and should not be used.

* fix: removes pacman build as it replaced by tar.zst and adds build:arch shortcut for pkgbuild

* aur: add proper VCS (-git) PKGBUILD

created clean VCS-based PKGBUILD following arch packaging conventions.

this explicitly marked as a rolling (-git) build and derives its version dynamically from git tags and commit history via pkgver(). previous hybrid approach has been changed.

key changes:
- use -git suffix to clearly indicate rolling source builds
- set pkgver=0 and compute the actual version via pkgver()
- build only a directory layout using electron-builder (--dir)
- avoid generating AppImage, deb, rpm, or pacman installers
- align build and package steps with Arch packaging guidelines

note: this PKGBUILD is intended for development and AUR use only and is not suitable for binary redistribution or release artifacts.

* ci: add fixed-version PKGBUILD for Arch Linux releases

this PKGBUILD intended for CI and GitHub release artifacts. targets tagged releases only and uses a fixed pkgver that matches the corresponding git tag. all of the VCS logic has been removed to PKGBUILD-git to ensure reproducible builds and stable versioning suitable for binary distribution.

the build process relies on electron-builder directory output (--dir) and packages only the unpacked application into a standard Arch Linux package (.pkg.tar.zst). other distro format are excluded from this path and handled separately.

this change establishes a clear separation between:
- rolling AUR development builds (-git)
- CI-generated, versioned Arch Linux release packages

the result is predictable artifact naming, correct version alignment, and Arch-compliant packaging for downstream users.

* Update README.md

adds information for Arch build

* Update README.md

BUILD.md location was changed and now this link is poiting to nothing

* Update PKGBUILD

* Update PKGBUILD-git

* chore: fix ubuntu/debian part in README.md

* Polish language support (#195)

* Update support_request.yml

Added hardware specification

* Update bug_report.yml

Add logs textfield to bug report

* chore: add changelog in README.md

* fix screenshot input in feature_request.yml

* add hardware spec input in bug_report.yml

* fix: PKGBUILD pkgname variable fix

* userdata migration [need review from other OS]

* french translate

* Add German and Swedish translations

Added de.json and sv.json locale files for German and Swedish language support. Updated i18n.js to register 'de' and 'sv' as available languages in the launcher.

* Update README.md

* chore: add offline-mode warning to the README.md

* chore: add downloads counter in README.md

* fix: Steam Deck/Ubuntu crash - use system libzstd.so

The bundled libzstd.so is incompatible with glibc 2.41's stricter heap
validation, causing "free(): invalid pointer" crashes.

Solution: Automatically replace bundled libzstd.so with system version
on Linux. The launcher detects and symlinks to /usr/lib/libzstd.so.1.

- Auto-detect system libzstd at common paths (Arch, Debian, Fedora)
- Backup bundled version as libzstd.so.bundled
- Create symlink to system version
- Add HYTALE_NO_LIBZSTD_FIX=1 to disable if needed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove Windows and Linux ARM64 information on the README.md

* Update support_request.yml

* fix: improve update system UX and macOS compatibility

Update System Improvements:
- Fix duplicate update popups by disabling legacy updater.js
- Add skip button to update popup (shows after 30s, on error, or after download)
- Add macOS-specific handling with manual download as primary option
- Add missing open-download-page IPC handler
- Add missing unblockInterface() method to properly clean up after popup close
- Add quitAndInstallUpdate alias in preload for compatibility
- Remove pulse animation when download completes
- Fix manual download button to show correct status and close popup
- Sync player name to settings input after first install

Client Patcher Cleanup:
- Remove server patching code (server uses pre-patched JAR from CDN)
- Simplify to client-only patching
- Remove unused imports (crypto, AdmZip, execSync, spawn, javaManager)
- Remove unused methods (stringToUtf8, findAndReplaceDomainUtf8)
- Move localhost dev code to backup file for reference

Code Quality Fixes:
- Fix duplicate DOMContentLoaded handlers in install.js
- Fix duplicate checkForUpdates definition in preload.js
- Fix redundant if/else in onProgressUpdate callback
- Fix typo "Harwadre" -> "Hardware" in preload.js

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add Russian language support

Added Russian (ru) to the list of available languages.

* chore: drafting documentation on SERVER.md

* Some updates in Russian language localization file

* fix

* Update ru.json

* Fixed Java runtime name and fixed typo

* fixed untranslated place

* Update ru.json

* Update ru.json

* Update ru.json

* Update ru.json

* Update ru.json

* fix: timeout getLatestClient 

fixes #138

* fix: change default version to 7.pwr in main.js

* fix: change default release version to 7.pwr

* fix: change version release to 7.pwr

* docs: Add comprehensive troubleshooting guide (#209)

Add TROUBLESHOOTING.md with solutions for common issues including:

- Windows: Firewall configuration, duplicate mods, SmartScreen
- Linux: GPU detection (NVIDIA/AMD), SDL3_image/libpng dependencies,
  Wayland/X11 issues, Steam Deck support
- macOS: Rosetta 2 for Apple Silicon, code signing, quarantine
- Connection: Server boot failures, regional restrictions
- Authentication: Token errors, config reset procedures
- Avatar/Cosmetics: F2P limitations documentation
- Backup locations for all platforms
- Log locations for bug reports

Solutions compiled from closed GitHub issues (#205, #155, #90, #60,
#144, #192) and community feedback.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* Standardize language codes, improve formatting, and update all locale files. (#224)

* Update German (Germany) localization

* Update Español (España) localization

* Update French (France) localization

* Update Polish (Poland) localization

* Update Portuguese (Brazil) localization

* Update Russian (Russia) localization

* Update Swedish (Sweden) localization

* Update Turkish (Turkey) localization

* Update language codes, names and alphabetical in i18n system

* Changed Spanish language name to the Formal name "Spanish (Spain)"

* Fix PKGBUILD-git

* Fix PKGBUILD

* delete cache after installation

* Enforce 16-char player name limit and update mod sync

Added a maxlength attribute to the player name input and enforced a 16-character limit in both install and settings scripts, providing user feedback if exceeded. Refactored modManager.js to replace symlink-based mod management with a copy-based system, copying enabled mods to HytaleSaves\Mods and removing legacy symlink logic to improve compatibility and avoid permission issues.

* Update installation subtitle

* chore: update quickstart link in README.md

* chore: delete warning of Ubuntu-Debian at Linux Prequisites section

* added featured server list from api

* Add Featured Servers page to GUI

* Update Discord invite URL in client patcher

* Add differential update system

* Remove launcher chat and add Discord popup

* fix: removed 'check disk space' alert on permission file error

* fix: upgrade tar to ^7.5.6 version

* fix: re-add universal arch for mac

* fix: upgrade electron/rebuild to 4.0.3

* fix: removed override tar version

* fix: pkgbuild version to 2.1.2

* fix: src.tar.zst and srcinfo missing files

* feat: add Indonesian language translation

* fix: GPU preference hint to Laptop-only

* feat: create two columns for settings page

* Add Discord invite link to rpc

* docs: add recordings form, fix OS list

* Release v2.2.0

* Release v2.2.0

* Release v2.2.0

* chore: delete icon.ico, moved to build folder

* chore: delete icon.png, moved to build folder

* fix: build and release for tag push-only in release.yml

* fix: gamescope steam deck issue fixes #186 hopefully

* Support branch selection for server patching

* chose: add auto-patch system for pre-release JAR

* fix: preserves arch x64 on linux target for #242

* fix: removed arm64 flags

* fix: redo package.json arch

* update package-lock.json

* Update release.yml

* chore: sync package-lock with package.json

* fix: reorder fedora libzstd paths to first iteration

* feat: enhance gpu detection, drafting

* fix: comprehensive UUID/username persistence bug fixes (#252)

* fix: comprehensive UUID/username persistence bug fixes

Major fixes for UUID/skin reset issues that caused players to lose cosmetics:

Core fixes:
- Username rename now preserves UUID (atomic rename, not new identity)
- Atomic config writes with backup/recovery system
- Case-insensitive UUID lookup with case-preserving storage
- Pre-launch validation blocks play if no username configured
- Removed saveUsername calls from launch/install flows

UUID Modal fixes:
- Fixed isCurrent badge showing on wrong user
- Added switch identity button to change between saved usernames
- Fixed custom UUID input using unsaved DOM username
- UUID list now refreshes when player name changes
- Enabled copy/paste in custom UUID input field

UI/UX improvements:
- Added translation keys for switch username functionality
- CSS user-select fix for UUID input fields
- Allowed Ctrl+V/C/X/A shortcuts in Electron

Files: config.js, gameLauncher.js, gameManager.js, playerManager.js,
launcher.js, settings.js, main.js, preload.js, style.css, en.json

See UUID_BUGS_FIX_PLAN.md for detailed bug list (18 bugs, 16 fixed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(i18n): add switch username translations to all locales

Added translation keys for username switching functionality:
- notifications.noUsername
- notifications.switchUsernameSuccess
- notifications.switchUsernameFailed
- notifications.playerNameTooLong
- confirm.switchUsernameTitle
- confirm.switchUsernameMessage
- confirm.switchUsernameButton

Languages updated: de-DE, es-ES, fr-FR, id-ID, pl-PL, pt-BR, ru-RU, sv-SE, tr-TR

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: move UUID_BUGS_FIX_PLAN.md to docs folder

* docs: update UUID_BUGS_FIX_PLAN with complete fix details

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* chore: rearrange, fix, and improve README.md

* chore: link downloads, platform, and version to release page in README.md

* chore: update discord link

* chore: insert contact link in CODE_OF_CONDUCT.md

* fix: missing version text on launcher

* chore: update quickstart button link to header

* chore: update discord link and give warning quickstart

* chore revise online play hosting instructions in README

Updated instructions for hosting an online game and clarified troubleshooting steps.

* Fix Turkish translations in tr-TR.json

* fix: EPERM error in Repair Game Button [windows testing needed]

* fix: invalid generated token that caused hangs on exit [windows testing needed]

* fix: major bug - hytale won't launch with laptop machine and ghost processes

* fix: discord RPC destroy error if not connected

* fix: major bug - detach game process to avoid launcher-held handles causing zombie process

* docs: add analysis on ghost process and launcher cleanup

* revert generateLocalTokens, wrong analysis on game launching issue

* revert add deps for generateLocalTokens

* Add proxy client and route downloads through it

* fix: Prevent JAR file corruption during proxy downloads

Fixed binary file corruption when downloading through proxy by using PassThrough stream to preserve data integrity while tracking download progress.

* Improve featured servers layout with Discord integration

- Add Discord button to server cards when discord link is present in API data
- Remove HF2P Servers section to use full width for featured servers
- Increase server card size (300x180px banner, larger fonts and spacing)
- Simplify layout from 2-column grid to single full-width container
- Discord button opens external browser with server invite link

* package version to 2.2.1

Update package.json version from 2.2.0 to 2.2.1 to publish a patch release.

* fix: add game_running_marker to prevent duplicate launches

* Add smart proxy with direct-fallback and logging

* fix: remove duplicate check

* fix: cache invalidation from .env prevents multiple launch attempts

for all env related, it is necessary to clear cache first, otherwise on few launch attempts the game wouldn't run

* fix: redact proxy_url and remove timed out emoji

* Prepare Release v2.2.1

* docs: enhance bug report template with placeholders and options

Updated the bug report template to include placeholders and additional Linux distributions.

* chore revise windows prerequisites and changelog

Updated prerequisites and changelog for version 2.2.1.

* chore: improvise badges, relocate star history, fix discord links

* chore: fix release notes for v2.2.1

* feat(macos): add code signing and notarization support

Add macOS code signing and notarization for Gatekeeper compatibility:

- Add hardened runtime configuration in package.json
- Add entitlements.mac.plist for required app permissions
- Enable built-in electron-builder notarization
- Add code signing and notarization secrets to workflow

Required GitHub Secrets:
- CSC_LINK: Base64-encoded .p12 certificate file
- CSC_KEY_PASSWORD: Password for the .p12 certificate
- APPLE_ID: Apple Developer account email
- APPLE_APP_SPECIFIC_PASSWORD: App-specific password from appleid.apple.com
- APPLE_TEAM_ID: 10-character Apple Developer Team ID

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Revise and enhance Hytale F2P Server Guide

Updated the Hytale F2P Server Guide with new sections and improved formatting.

* Update SERVER.md official accounts info

Added CloudNord hosting information and new section for playing online with official accounts.

* Update SERVER.md

* refactor: replace pre-patched JAR download with ByteBuddy agent

Migrate from downloading pre-patched server JARs from CDN to downloading
the DualAuth ByteBuddy Agent from GitHub releases. The server JAR stays
pristine - auth patching happens at runtime via -javaagent: flag.

clientPatcher.js:
- Replace patchServer() with ensureAgentAvailable()
- Download dualauth-agent.jar to Server/ directory
- Remove serverJarContainsDualAuth() and validateServerJarSize()

gameLauncher.js:
- Set JAVA_TOOL_OPTIONS env var with -javaagent: for runtime patching
- Update logging to show agent status instead of server patch count

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Bump package version to 2.2.2

Update package.json version from 2.2.1 to 2.2.2 to mark a patch release.

* Use new version API and default to v8 PWR

---------

Co-authored-by: Fazri Gading <fazrigading@gmail.com>
Co-authored-by: TalesAmaral <57869141+TalesAmaral@users.noreply.github.com>
Co-authored-by: walti0 <95646872+walti0@users.noreply.github.com>
Co-authored-by: Fazri Gading <super.fai700@gmail.com>
Co-authored-by: sanasol <mail@sanasol.ws>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Terromur <79866197+Terromur@users.noreply.github.com>
Co-authored-by: Zakhar Smokotov <zaharb840@gmail.com>
Co-authored-by: xSamiVS <samtaiebc@gmail.com>
Co-authored-by: MetricsLite <66024355+MetricsLite@users.noreply.github.com>
2026-02-11 11:13:14 +01:00

681 lines
22 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const { smartDownloadStream } = require('./proxyClient');
// Domain configuration
const ORIGINAL_DOMAIN = 'hytale.com';
const MIN_DOMAIN_LENGTH = 4;
const MAX_DOMAIN_LENGTH = 16;
// DualAuth ByteBuddy Agent (runtime class transformation, no JAR modification)
const DUALAUTH_AGENT_URL = 'https://github.com/sanasol/hytale-auth-server/releases/latest/download/dualauth-agent.jar';
const DUALAUTH_AGENT_FILENAME = 'dualauth-agent.jar';
function getTargetDomain() {
if (process.env.HYTALE_AUTH_DOMAIN) {
return process.env.HYTALE_AUTH_DOMAIN;
}
try {
const { getAuthDomain } = require('../core/config');
return getAuthDomain();
} catch (e) {
return 'auth.sanasol.ws';
}
}
const DEFAULT_NEW_DOMAIN = 'auth.sanasol.ws';
/**
* Patches HytaleClient binary to replace hytale.com with custom domain
* Server auth is handled by DualAuth ByteBuddy Agent (-javaagent: flag)
*
* Supports domains from 4 to 16 characters:
* - All F2P traffic routes to single endpoint: https://{domain} (no subdomains)
* - Domains <= 10 chars: Direct replacement, subdomains stripped
* - Domains 11-16 chars: Split mode - first 6 chars replace subdomain prefix, rest replaces domain
*/
class ClientPatcher {
constructor() {
this.patchedFlag = '.patched_custom';
}
/**
* Get the target domain for patching
*/
getNewDomain() {
const domain = getTargetDomain();
if (domain.length < MIN_DOMAIN_LENGTH) {
console.warn(`Warning: Domain "${domain}" is too short (min ${MIN_DOMAIN_LENGTH} chars)`);
console.warn(`Using default domain: ${DEFAULT_NEW_DOMAIN}`);
return DEFAULT_NEW_DOMAIN;
}
if (domain.length > MAX_DOMAIN_LENGTH) {
console.warn(`Warning: Domain "${domain}" is too long (max ${MAX_DOMAIN_LENGTH} chars)`);
console.warn(`Using default domain: ${DEFAULT_NEW_DOMAIN}`);
return DEFAULT_NEW_DOMAIN;
}
return domain;
}
/**
* Calculate the domain patching strategy based on length
*/
getDomainStrategy(domain) {
if (domain.length <= 10) {
return {
mode: 'direct',
mainDomain: domain,
subdomainPrefix: '',
description: `Direct replacement: hytale.com -> ${domain}`
};
} else {
const prefix = domain.slice(0, 6);
const suffix = domain.slice(6);
return {
mode: 'split',
mainDomain: suffix,
subdomainPrefix: prefix,
description: `Split mode: subdomain prefix="${prefix}", main domain="${suffix}"`
};
}
}
/**
* Convert a string to the length-prefixed byte format used by the client
*/
stringToLengthPrefixed(str) {
const length = str.length;
const result = Buffer.alloc(4 + length + (length - 1));
result[0] = length;
result[1] = 0x00;
result[2] = 0x00;
result[3] = 0x00;
let pos = 4;
for (let i = 0; i < length; i++) {
result[pos++] = str.charCodeAt(i);
if (i < length - 1) {
result[pos++] = 0x00;
}
}
return result;
}
/**
* 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;
}
/**
* 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;
}
/**
* Replace bytes in buffer - only overwrites the length of new bytes
*/
replaceBytes(buffer, oldBytes, newBytes) {
let count = 0;
const result = Buffer.from(buffer);
if (newBytes.length > oldBytes.length) {
console.warn(` Warning: New pattern (${newBytes.length}) longer than old (${oldBytes.length}), skipping`);
return { buffer: result, count: 0 };
}
const positions = this.findAllOccurrences(result, oldBytes);
for (const pos of positions) {
newBytes.copy(result, pos);
count++;
}
return { buffer: result, count };
}
/**
* Smart domain replacement that handles both null-terminated and non-null-terminated strings
*/
findAndReplaceDomainSmart(data, oldDomain, newDomain) {
let count = 0;
const result = Buffer.from(data);
const oldUtf16NoLast = this.stringToUtf16LE(oldDomain.slice(0, -1));
const newUtf16NoLast = this.stringToUtf16LE(newDomain.slice(0, -1));
const oldLastCharByte = oldDomain.charCodeAt(oldDomain.length - 1);
const newLastCharByte = newDomain.charCodeAt(newDomain.length - 1);
const positions = this.findAllOccurrences(result, oldUtf16NoLast);
for (const pos of positions) {
const lastCharPos = pos + oldUtf16NoLast.length;
if (lastCharPos + 1 > result.length) continue;
const lastCharFirstByte = result[lastCharPos];
if (lastCharFirstByte === oldLastCharByte) {
newUtf16NoLast.copy(result, pos);
result[lastCharPos] = newLastCharByte;
if (lastCharPos + 1 < result.length) {
const secondByte = result[lastCharPos + 1];
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 };
}
/**
* Apply all domain patches using length-prefixed format
*/
applyDomainPatches(data, domain, protocol = 'https://') {
let result = Buffer.from(data);
let totalCount = 0;
const strategy = this.getDomainStrategy(domain);
console.log(` Patching strategy: ${strategy.description}`);
// 1. Patch telemetry/sentry URL
const oldSentry = 'https://ca900df42fcf57d4dd8401a86ddd7da2@sentry.hytale.com/2';
const newSentry = `${protocol}t@${domain}/2`;
console.log(` Patching sentry: ${oldSentry.slice(0, 30)}... -> ${newSentry}`);
const sentryResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(oldSentry),
this.stringToLengthPrefixed(newSentry)
);
result = sentryResult.buffer;
if (sentryResult.count > 0) {
console.log(` Replaced ${sentryResult.count} sentry occurrence(s)`);
totalCount += sentryResult.count;
}
// 2. Patch main domain
console.log(` Patching domain: ${ORIGINAL_DOMAIN} -> ${strategy.mainDomain}`);
const domainResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(ORIGINAL_DOMAIN),
this.stringToLengthPrefixed(strategy.mainDomain)
);
result = domainResult.buffer;
if (domainResult.count > 0) {
console.log(` Replaced ${domainResult.count} domain occurrence(s)`);
totalCount += domainResult.count;
}
// 3. Patch subdomain prefixes
const subdomains = ['https://tools.', 'https://sessions.', 'https://account-data.', 'https://telemetry.'];
const newSubdomainPrefix = protocol + strategy.subdomainPrefix;
for (const sub of subdomains) {
console.log(` Patching subdomain: ${sub} -> ${newSubdomainPrefix}`);
const subResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(sub),
this.stringToLengthPrefixed(newSubdomainPrefix)
);
result = subResult.buffer;
if (subResult.count > 0) {
console.log(` Replaced ${subResult.count} occurrence(s)`);
totalCount += subResult.count;
}
}
return { buffer: result, count: totalCount };
}
/**
* Patch Discord invite URLs
*/
patchDiscordUrl(data) {
let count = 0;
const result = Buffer.from(data);
const oldUrl = '.gg/hytale';
const newUrl = '.gg/hf2pdc';
const lpResult = this.replaceBytes(
result,
this.stringToLengthPrefixed(oldUrl),
this.stringToLengthPrefixed(newUrl)
);
if (lpResult.count > 0) {
return { buffer: lpResult.buffer, count: lpResult.count };
}
// Fallback to UTF-16LE
const oldUtf16 = this.stringToUtf16LE(oldUrl);
const newUtf16 = this.stringToUtf16LE(newUrl);
const positions = this.findAllOccurrences(result, oldUtf16);
for (const pos of positions) {
newUtf16.copy(result, pos);
count++;
}
return { buffer: result, count };
}
/**
* Check patch status of client binary
*/
getPatchStatus(clientPath) {
const newDomain = this.getNewDomain();
const patchFlagFile = clientPath + this.patchedFlag;
if (fs.existsSync(patchFlagFile)) {
try {
const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8'));
const currentDomain = flagData.targetDomain;
if (currentDomain === newDomain) {
const data = fs.readFileSync(clientPath);
const strategy = this.getDomainStrategy(newDomain);
const domainPattern = this.stringToLengthPrefixed(strategy.mainDomain);
if (data.includes(domainPattern)) {
return { patched: true, currentDomain, needsRestore: false };
} else {
console.log(' Flag exists but binary not patched (was updated?), needs re-patching...');
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) {
// 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;
}
/**
* Mark client as patched
*/
markAsPatched(clientPath) {
const newDomain = this.getNewDomain();
const strategy = this.getDomainStrategy(newDomain);
const patchFlagFile = clientPath + this.patchedFlag;
const flagData = {
patchedAt: new Date().toISOString(),
originalDomain: ORIGINAL_DOMAIN,
targetDomain: newDomain,
patchMode: strategy.mode,
mainDomain: strategy.mainDomain,
subdomainPrefix: strategy.subdomainPrefix,
patcherVersion: '2.1.0',
verified: 'binary_contents'
};
fs.writeFileSync(patchFlagFile, JSON.stringify(flagData, null, 2));
}
/**
* Create backup of original client binary
*/
backupClient(clientPath) {
const backupPath = clientPath + '.original';
try {
if (!fs.existsSync(backupPath)) {
console.log(` Creating backup at ${path.basename(backupPath)}`);
fs.copyFileSync(clientPath, backupPath);
return backupPath;
}
const currentSize = fs.statSync(clientPath).size;
const backupSize = fs.statSync(backupPath).size;
if (currentSize !== backupSize) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const oldBackupPath = `${clientPath}.original.${timestamp}`;
console.log(` File updated, archiving old backup to ${path.basename(oldBackupPath)}`);
fs.renameSync(backupPath, oldBackupPath);
fs.copyFileSync(clientPath, backupPath);
return backupPath;
}
console.log(' Backup already exists');
return backupPath;
} catch (e) {
console.error(` Failed to create backup: ${e.message}`);
return null;
}
}
/**
* Restore original client binary
*/
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
*/
async patchClient(clientPath, progressCallback) {
const newDomain = this.getNewDomain();
const strategy = this.getDomainStrategy(newDomain);
console.log('=== Client Patcher v2.1 ===');
console.log(`Target: ${clientPath}`);
console.log(`Domain: ${newDomain} (${newDomain.length} chars)`);
console.log(`Mode: ${strategy.mode}`);
if (strategy.mode === 'split') {
console.log(` Subdomain prefix: ${strategy.subdomainPrefix}`);
console.log(` Main domain: ${strategy.mainDomain}`);
}
if (!fs.existsSync(clientPath)) {
const error = `Client binary not found: ${clientPath}`;
console.error(error);
return { success: false, error };
}
const patchStatus = this.getPatchStatus(clientPath);
if (patchStatus.patched) {
console.log(`Client already patched for ${newDomain}, skipping`);
if (progressCallback) progressCallback('Client already patched', 100);
return { success: true, alreadyPatched: true, patchCount: 0 };
}
if (patchStatus.needsRestore) {
if (progressCallback) progressCallback('Restoring original for domain change...', 5);
this.restoreFromBackup(clientPath);
}
if (progressCallback) progressCallback('Preparing to patch client...', 10);
console.log('Creating backup...');
const backupResult = this.backupClient(clientPath);
if (!backupResult) {
console.warn(' Could not create backup - proceeding without backup');
}
if (progressCallback) progressCallback('Reading client binary...', 20);
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);
console.log('Applying domain patches (length-prefixed format)...');
const { buffer: patchedData, count } = this.applyDomainPatches(data, newDomain);
console.log('Patching Discord URLs...');
const { buffer: finalData, count: discordCount } = this.patchDiscordUrl(patchedData);
if (count === 0 && discordCount === 0) {
console.log('No occurrences found - trying legacy UTF-16LE format...');
const legacyResult = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, strategy.mainDomain);
if (legacyResult.count > 0) {
console.log(`Found ${legacyResult.count} occurrences with legacy format`);
fs.writeFileSync(clientPath, legacyResult.buffer);
this.markAsPatched(clientPath);
return { success: true, patchCount: legacyResult.count, format: 'legacy' };
}
console.log('No occurrences found - binary may already be modified or has different format');
return { success: true, patchCount: 0, warning: 'No occurrences found' };
}
if (progressCallback) progressCallback('Writing patched binary...', 80);
console.log('Writing patched binary...');
fs.writeFileSync(clientPath, finalData);
this.markAsPatched(clientPath);
if (progressCallback) progressCallback('Patching complete', 100);
console.log(`Successfully patched ${count} domain occurrences and ${discordCount} Discord URLs`);
console.log('=== Patching Complete ===');
return { success: true, patchCount: count + discordCount };
}
/**
* Get the path to the DualAuth Agent JAR in a directory
*/
getAgentPath(dir) {
return path.join(dir, DUALAUTH_AGENT_FILENAME);
}
/**
* Download DualAuth ByteBuddy Agent (replaces old pre-patched JAR approach)
* The agent provides runtime class transformation via -javaagent: flag
* No server JAR modification needed - original JAR stays pristine
*/
async ensureAgentAvailable(serverDir, progressCallback) {
const agentPath = this.getAgentPath(serverDir);
console.log('=== DualAuth Agent (ByteBuddy) ===');
console.log(`Target: ${agentPath}`);
// Check if agent already exists and is valid
if (fs.existsSync(agentPath)) {
try {
const stats = fs.statSync(agentPath);
if (stats.size > 1024) {
console.log(`DualAuth Agent present (${(stats.size / 1024).toFixed(0)} KB)`);
if (progressCallback) progressCallback('DualAuth Agent ready', 100);
return { success: true, agentPath, alreadyExists: true };
}
// File exists but too small - corrupt, re-download
console.log('Agent file appears corrupt, re-downloading...');
fs.unlinkSync(agentPath);
} catch (e) {
console.warn('Could not check agent file:', e.message);
}
}
// Download agent from GitHub releases
if (progressCallback) progressCallback('Downloading DualAuth Agent...', 20);
console.log(`Downloading from: ${DUALAUTH_AGENT_URL}`);
try {
// Ensure server directory exists
if (!fs.existsSync(serverDir)) {
fs.mkdirSync(serverDir, { recursive: true });
}
const tmpPath = agentPath + '.tmp';
const file = fs.createWriteStream(tmpPath);
const stream = await smartDownloadStream(DUALAUTH_AGENT_URL, (chunk, downloadedBytes, total) => {
if (progressCallback && total) {
const percent = 20 + Math.floor((downloadedBytes / total) * 70);
progressCallback(`Downloading agent... ${(downloadedBytes / 1024).toFixed(0)} KB`, percent);
}
});
stream.pipe(file);
await new Promise((resolve, reject) => {
file.on('finish', () => { file.close(); resolve(); });
file.on('error', reject);
stream.on('error', reject);
});
// Verify download
const stats = fs.statSync(tmpPath);
if (stats.size < 1024) {
fs.unlinkSync(tmpPath);
const error = 'Downloaded agent too small (corrupt or failed download)';
console.error(error);
return { success: false, error };
}
// Atomic move
if (fs.existsSync(agentPath)) {
fs.unlinkSync(agentPath);
}
fs.renameSync(tmpPath, agentPath);
console.log(`DualAuth Agent downloaded (${(stats.size / 1024).toFixed(0)} KB)`);
if (progressCallback) progressCallback('DualAuth Agent ready', 100);
return { success: true, agentPath };
} catch (downloadError) {
console.error(`Failed to download DualAuth Agent: ${downloadError.message}`);
// Clean up temp file
const tmpPath = agentPath + '.tmp';
if (fs.existsSync(tmpPath)) {
try { fs.unlinkSync(tmpPath); } catch (e) { /* ignore */ }
}
return { success: false, error: downloadError.message };
}
}
/**
* Find client binary path based on platform
*/
findClientPath(gameDir) {
const candidates = [];
if (process.platform === 'darwin') {
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 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 client is patched and DualAuth Agent is available before launching
*/
async ensureClientPatched(gameDir, progressCallback, javaPath = null, branch = 'release') {
const results = {
client: null,
agent: null,
success: true
};
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' };
}
// Download DualAuth ByteBuddy Agent (runtime patching, no JAR modification)
const serverDir = path.join(gameDir, 'Server');
if (fs.existsSync(serverDir)) {
if (progressCallback) progressCallback('Checking DualAuth Agent...', 50);
results.agent = await this.ensureAgentAvailable(serverDir, (msg, pct) => {
if (progressCallback) {
progressCallback(`Agent: ${msg}`, pct ? 50 + pct / 2 : null);
}
});
} else {
console.warn('Server directory not found, skipping agent download');
results.agent = { success: true, skipped: true };
}
results.success = (results.client && results.client.success) || (results.agent && results.agent.success);
results.alreadyPatched = (results.client && results.client.alreadyPatched) && (results.agent && results.agent.alreadyExists);
results.patchCount = results.client ? results.client.patchCount || 0 : 0;
if (progressCallback) progressCallback('Patching complete', 100);
return results;
}
}
module.exports = new ClientPatcher();