mirror of
https://git.sanhost.net/sanasol/hytale-f2p
synced 2026-02-26 19:51:47 -03:00
Compare commits
17 Commits
v2
...
v2.0.4-aut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6041c1908 | ||
|
|
7b6c07283a | ||
|
|
300616ba82 | ||
|
|
115e76e461 | ||
|
|
9fdd6f1f44 | ||
|
|
f0b2342c71 | ||
|
|
7dbc900338 | ||
|
|
bc31f58c9c | ||
|
|
7e5a1577a3 | ||
|
|
84f0c0ba71 | ||
|
|
7ede6c2f27 | ||
|
|
be1a24a992 | ||
|
|
9a751958b0 | ||
|
|
9fcf603e08 | ||
|
|
4bc1661587 | ||
|
|
512a53aee7 | ||
|
|
cabb5a57d2 |
17
BUILD.md
17
BUILD.md
@@ -36,19 +36,4 @@ npm run build:mac
|
|||||||
npm run build:all
|
npm run build:all
|
||||||
```
|
```
|
||||||
|
|
||||||
## Output
|
Built executables will be in the `dist/` directory
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-black text-white overflow-hidden font-sans select-none" tabindex="-1">
|
<body class="bg-black text-white overflow-hidden font-sans select-none" tabindex="-1">
|
||||||
<div class="absolute inset-0 z-0">
|
<div class="absolute inset-0 z-0">
|
||||||
@@ -313,6 +313,18 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-option">
|
||||||
|
<div class="settings-button-group">
|
||||||
|
<button id="openGameLocationBtn" class="settings-action-btn" onclick="openGameLocation()">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
<div class="btn-content">
|
||||||
|
<div class="btn-title">Open Game Location</div>
|
||||||
|
<div class="btn-description">Open the game installation folder</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -417,7 +429,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script type="module" src="js/script.js"></script>
|
<script type="module" src="js/script.js"></script> <!-- Discord Notification -->
|
||||||
|
<div id="discordNotification" class="discord-notification">
|
||||||
|
<div class="notification-content">
|
||||||
|
<i class="fab fa-discord"></i>
|
||||||
|
<span class="notification-text">Join our Discord community!</span>
|
||||||
|
<button class="notification-action" onclick="window.electronAPI?.openExternal('https://discord.gg/n6HZ7NwSQd')">
|
||||||
|
Join Discord
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="notification-close" onclick="closeDiscordNotification()">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<script type="module" src="js/update.js"></script>
|
<script type="module" src="js/update.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -30,6 +30,24 @@ export function setupInstallation() {
|
|||||||
if (installPlayerName) {
|
if (installPlayerName) {
|
||||||
installPlayerName.addEventListener('change', savePlayerName);
|
installPlayerName.addEventListener('change', savePlayerName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.electronAPI && window.electronAPI.onProgressUpdate) {
|
||||||
|
window.electronAPI.onProgressUpdate((data) => {
|
||||||
|
if (window.LauncherUI) {
|
||||||
|
window.LauncherUI.showProgress();
|
||||||
|
window.LauncherUI.updateProgress(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.electronAPI && window.electronAPI.onProgressComplete) {
|
||||||
|
window.electronAPI.onProgressComplete(() => {
|
||||||
|
if (window.LauncherUI) {
|
||||||
|
window.LauncherUI.hideProgress();
|
||||||
|
}
|
||||||
|
resetInstallButton();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installGame() {
|
export async function installGame() {
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ export function setupLauncher() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.electronAPI && window.electronAPI.onProgressComplete) {
|
||||||
|
window.electronAPI.onProgressComplete(() => {
|
||||||
|
if (window.LauncherUI) {
|
||||||
|
window.LauncherUI.hideProgress();
|
||||||
|
}
|
||||||
|
resetPlayButton();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function launch() {
|
export async function launch() {
|
||||||
|
|||||||
@@ -7,3 +7,36 @@ import './players.js';
|
|||||||
import './chat.js';
|
import './chat.js';
|
||||||
import './settings.js';
|
import './settings.js';
|
||||||
|
|
||||||
|
// Discord notification functions
|
||||||
|
window.closeDiscordNotification = function() {
|
||||||
|
const notification = document.getElementById('discordNotification');
|
||||||
|
if (notification) {
|
||||||
|
notification.classList.add('hidden');
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.style.display = 'none';
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show notification after a delay
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const notification = document.getElementById('discordNotification');
|
||||||
|
if (notification) {
|
||||||
|
// Check if user has previously dismissed the notification
|
||||||
|
const dismissed = localStorage.getItem('discordNotificationDismissed');
|
||||||
|
if (!dismissed) {
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.style.display = 'flex';
|
||||||
|
}, 3000); // Show after 3 seconds
|
||||||
|
} else {
|
||||||
|
notification.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remember when user closes notification
|
||||||
|
const originalClose = window.closeDiscordNotification;
|
||||||
|
window.closeDiscordNotification = function() {
|
||||||
|
localStorage.setItem('discordNotificationDismissed', 'true');
|
||||||
|
originalClose();
|
||||||
|
};
|
||||||
@@ -119,6 +119,15 @@ async function loadAllSettings() {
|
|||||||
await loadPlayerName();
|
await loadPlayerName();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openGameLocation() {
|
||||||
|
try {
|
||||||
|
if (window.electronAPI && window.electronAPI.openGameLocation) {
|
||||||
|
await window.electronAPI.openGameLocation();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error opening game location:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getCurrentJavaPath() {
|
export function getCurrentJavaPath() {
|
||||||
if (customJavaCheck && customJavaCheck.checked && customJavaPath) {
|
if (customJavaCheck && customJavaCheck.checked && customJavaPath) {
|
||||||
@@ -135,6 +144,9 @@ export function getCurrentPlayerName() {
|
|||||||
return 'Player';
|
return 'Player';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make openGameLocation globally available
|
||||||
|
window.openGameLocation = openGameLocation;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initSettings);
|
document.addEventListener('DOMContentLoaded', initSettings);
|
||||||
|
|
||||||
window.SettingsAPI = {
|
window.SettingsAPI = {
|
||||||
|
|||||||
29
GUI/js/ui.js
29
GUI/js/ui.js
@@ -196,8 +196,26 @@ function setupFirstLaunchHandlers() {
|
|||||||
updateProgress(data);
|
updateProgress(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let lockButtonTimeout = null;
|
||||||
|
|
||||||
window.electronAPI.onLockPlayButton((locked) => {
|
window.electronAPI.onLockPlayButton((locked) => {
|
||||||
lockPlayButton(locked);
|
lockPlayButton(locked);
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
if (lockButtonTimeout) {
|
||||||
|
clearTimeout(lockButtonTimeout);
|
||||||
|
}
|
||||||
|
lockButtonTimeout = setTimeout(() => {
|
||||||
|
console.warn('Play button has been locked for too long, forcing unlock');
|
||||||
|
lockPlayButton(false);
|
||||||
|
lockButtonTimeout = null;
|
||||||
|
}, 20000);
|
||||||
|
} else {
|
||||||
|
if (lockButtonTimeout) {
|
||||||
|
clearTimeout(lockButtonTimeout);
|
||||||
|
lockButtonTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +466,17 @@ function setupUI() {
|
|||||||
|
|
||||||
lockPlayButton(true);
|
lockPlayButton(true);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const playButton = document.getElementById('homePlayBtn');
|
||||||
|
if (playButton && playButton.getAttribute('data-locked') === 'true') {
|
||||||
|
const spanElement = playButton.querySelector('span');
|
||||||
|
if (spanElement && spanElement.textContent === 'CHECKING...') {
|
||||||
|
console.warn('Play button still locked after startup timeout, forcing unlock');
|
||||||
|
lockPlayButton(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
handleNavigation();
|
handleNavigation();
|
||||||
setupWindowControls();
|
setupWindowControls();
|
||||||
setupSidebarLogo();
|
setupSidebarLogo();
|
||||||
|
|||||||
174
GUI/style.css
174
GUI/style.css
@@ -180,21 +180,6 @@ body {
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.players-counter.updated {
|
|
||||||
animation: pulse 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% {
|
|
||||||
transform: scale(1);
|
|
||||||
box-shadow: 0 0 0 rgba(147, 51, 234, 0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 0 20px rgba(147, 51, 234, 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.players-counter i {
|
.players-counter i {
|
||||||
color: #9333ea;
|
color: #9333ea;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -284,6 +269,108 @@ body {
|
|||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.discord-notification {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(88, 101, 242, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 300px;
|
||||||
|
z-index: 10000;
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
animation: slideIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-notification .fab.fa-discord {
|
||||||
|
color: #5865f2;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-text {
|
||||||
|
color: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-action {
|
||||||
|
background: #5865f2;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-action:hover {
|
||||||
|
background: #4752c4;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close i {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-notification.hidden {
|
||||||
|
animation: slideOut 0.3s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOut {
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.control-btn {
|
.control-btn {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -3796,6 +3883,63 @@ body {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-button-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-action-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: #9333ea;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(147, 51, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-action-btn i {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #9333ea;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-description {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-input-with-button {
|
.settings-input-with-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
[](https://discord.gg/MHkEjepMQ7)
|
|
||||||
|
|
||||||
**A modern, cross-platform offline launcher for Hytale with automatic updates and multiplayer support (windows users & non-premium only)**
|
**A modern, cross-platform offline launcher for Hytale with automatic updates and multiplayer support (windows users & non-premium only)**
|
||||||
|
|
||||||
@@ -59,23 +58,14 @@
|
|||||||
3. Launch from desktop or start menu
|
3. Launch from desktop or start menu
|
||||||
|
|
||||||
#### Linux
|
#### Linux
|
||||||
See [BUILD.md](BUILD.md) for detailed build instructions.
|
See [BUILD.md](BUILD.md) for detailed build instructions or [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases) section.
|
||||||
|
|
||||||
#### macOS
|
#### macOS
|
||||||
See [BUILD.md](BUILD.md) for detailed build instructions.
|
See [BUILD.md](BUILD.md) for detailed build instructions or [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases) section.
|
||||||
|
|
||||||
#### 🖥️ How to create server (Windows Only)?
|
#### 🖥️ How to create server (Windows Only)?
|
||||||
1. Download the server files directly from: `http://3.10.208.30:3002/server`
|
See [SERVER.md](SERVER.md)
|
||||||
2. Replace the existing files in your `HytaleF2P` installation folder
|
|
||||||
3. Run the server launcher (.bat) to start hosting your own Hytale server
|
|
||||||
4. You will need a third party software like Radmin VPN (check on youtube how to use Radmin VPN).
|
|
||||||
|
|
||||||
### 🎮 Usage
|
|
||||||
|
|
||||||
1. **Enter your player name**
|
|
||||||
2. **Click "PLAY"**
|
|
||||||
3. **Automatic setup** - The launcher handles everything automatically
|
|
||||||
4. **Game launches** - Enjoy playing Hytale!
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,7 +77,17 @@ See [BUILD.md](BUILD.md) for comprehensive build instructions.
|
|||||||
|
|
||||||
## 📋 Changelog
|
## 📋 Changelog
|
||||||
|
|
||||||
### 🆕 v2.0.0 *(Latest)*
|
### 🆕 v2.0.1 *(Latest)*
|
||||||
|
- 📊 **Advanced Logging System** - Complete logging with timestamps, file rotation, and session tracking
|
||||||
|
- 🔧 **Play Button Fix** - Resolved issue where play button could get stuck in "CHECKING..." state
|
||||||
|
- 💬 **Discord Integration** - Added closable Discord notification for community engagement
|
||||||
|
- 📁 **Game Location Access** - New "Open Game Location" button in settings for easy file access
|
||||||
|
- 🎯 **UI Polish** - Removed bounce animation from player counter for smoother experience
|
||||||
|
- 🛡️ **Stability Improvements** - Enhanced error handling and process lifecycle management
|
||||||
|
- ⚡ **Performance Optimizations** - Faster startup times and better resource management
|
||||||
|
- 🔄 **Timeout Protection** - Added safety timeouts to prevent launcher freezing
|
||||||
|
|
||||||
|
### 🔄 v2.0.0
|
||||||
- ✅ **Automatic Game Update System** - Smart version checking and seamless updates
|
- ✅ **Automatic Game Update System** - Smart version checking and seamless updates
|
||||||
- ✅ **Partial Automatic Launcher Update System** - This will inform you when I release a new update.
|
- ✅ **Partial Automatic Launcher Update System** - This will inform you when I release a new update.
|
||||||
- 🛡️ **UserData Preservation** - Intelligent backup/restore of game saves during updates
|
- 🛡️ **UserData Preservation** - Intelligent backup/restore of game saves during updates
|
||||||
@@ -187,11 +187,6 @@ This launcher is created for **educational purposes only**.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📬 Contact
|
|
||||||
|
|
||||||
[](https://discord.com/users/1433515183606599873)
|
|
||||||
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**⭐ Star this project if you found it helpful! ⭐**
|
**⭐ Star this project if you found it helpful! ⭐**
|
||||||
|
|||||||
87
SERVER.md
Normal file
87
SERVER.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Hytale F2P Server Setup Guide
|
||||||
|
|
||||||
|
## Server File Setup
|
||||||
|
|
||||||
|
**Download server file:**
|
||||||
|
```
|
||||||
|
https://files.hytalef2p.com/server
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace the file here:**
|
||||||
|
`<your_path>\HytaleF2P\release\package\game\latest\Server`
|
||||||
|
|
||||||
|
If you don't have any custom installation path:
|
||||||
|
|
||||||
|
1. Press **WIN + R**
|
||||||
|
2. Type: `%localappdata%\HytaleF2P\release\package\game\latest\Server`
|
||||||
|
3. Press **Enter**
|
||||||
|
|
||||||
|
You will be redirected to the correct folder automatically.
|
||||||
|
|
||||||
|
## Network Setup - Radmin VPN Required
|
||||||
|
|
||||||
|
**Important:** The server only supports third-party software for LAN-style connections. You must use **Radmin VPN** to connect players together.
|
||||||
|
|
||||||
|
1. **Download and install [Radmin VPN](https://www.radmin-vpn.com/)**
|
||||||
|
2. **Create or join a network** in Radmin VPN
|
||||||
|
3. **All players must be connected** to the same Radmin network
|
||||||
|
4. **Use the Radmin VPN IP address** to connect to the server
|
||||||
|
|
||||||
|
This creates a virtual LAN environment that allows the Hytale server to work properly with multiple players.
|
||||||
|
|
||||||
|
## RAM Allocation Guide (Windows)
|
||||||
|
|
||||||
|
When you start a Hytale server using `start-server.bat`, Java will use very little memory by default.
|
||||||
|
This can cause slow startup, crashes, or the server not launching at all.
|
||||||
|
|
||||||
|
**You should always allocate RAM in your launch command.**
|
||||||
|
|
||||||
|
Edit your `start-server.bat` file and use the version that matches your PC:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PC with 4 GB RAM
|
||||||
|
*Best for small servers / testing*
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -Xms512M -Xmx2G -jar HytaleServer.jar --assets ..\Assets.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
- Uses up to **2 GB**
|
||||||
|
- Leaves enough memory for Windows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PC with 8 GB RAM
|
||||||
|
*Good for small communities*
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -Xms1G -Xmx4G -jar HytaleServer.jar --assets ..\Assets.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
- Uses up to **4 GB**
|
||||||
|
- Stable for most setups
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PC with 16 GB RAM
|
||||||
|
*Perfect for large or modded servers*
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -Xms2G -Xmx8G -jar HytaleServer.jar --assets ..\Assets.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
- Uses up to **8 GB**
|
||||||
|
- Ideal for heavy worlds and plugins
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- `-Xms` = minimum RAM allocation
|
||||||
|
- `-Xmx` = maximum RAM allocation
|
||||||
|
- **Never allocate all your system RAM** — Windows still needs memory to run
|
||||||
|
- **Test your configuration** with a small world first
|
||||||
|
- **Monitor server performance** and adjust RAM as needed
|
||||||
|
|
||||||
|
|
||||||
197
backend/core/config.js
Normal file
197
backend/core/config.js
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
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() {
|
||||||
|
const home = os.homedir();
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return path.join(home, 'AppData', 'Local', 'HytaleF2P');
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
return path.join(home, 'Library', 'Application Support', 'HytaleF2P');
|
||||||
|
} else {
|
||||||
|
return path.join(home, '.hytalef2p');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_FILE = path.join(getAppDir(), 'config.json');
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
|
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Notice: could not load config:', err.message);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig(update) {
|
||||||
|
try {
|
||||||
|
const configDir = path.dirname(CONFIG_FILE);
|
||||||
|
if (!fs.existsSync(configDir)) {
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const config = loadConfig();
|
||||||
|
const next = { ...config, ...update };
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Notice: could not save config:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveUsername(username) {
|
||||||
|
saveConfig({ username: username || 'Player' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUsername() {
|
||||||
|
const config = loadConfig();
|
||||||
|
return config.username || 'Player';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveChatUsername(chatUsername) {
|
||||||
|
saveConfig({ chatUsername: chatUsername || '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadChatUsername() {
|
||||||
|
const config = loadConfig();
|
||||||
|
return config.chatUsername || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUuidForUser(username) {
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const config = loadConfig();
|
||||||
|
const userUuids = config.userUuids || {};
|
||||||
|
|
||||||
|
if (userUuids[username]) {
|
||||||
|
return userUuids[username];
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUuid = uuidv4();
|
||||||
|
userUuids[username] = newUuid;
|
||||||
|
saveConfig({ userUuids });
|
||||||
|
|
||||||
|
return newUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveJavaPath(javaPath) {
|
||||||
|
const trimmed = (javaPath || '').trim();
|
||||||
|
saveConfig({ javaPath: trimmed });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadJavaPath() {
|
||||||
|
const config = loadConfig();
|
||||||
|
return config.javaPath || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveInstallPath(installPath) {
|
||||||
|
const trimmed = (installPath || '').trim();
|
||||||
|
saveConfig({ installPath: trimmed });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadInstallPath() {
|
||||||
|
const config = loadConfig();
|
||||||
|
return config.installPath || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveModsToConfig(mods) {
|
||||||
|
try {
|
||||||
|
let config = loadConfig();
|
||||||
|
config.installedMods = mods;
|
||||||
|
|
||||||
|
const configDir = path.dirname(CONFIG_FILE);
|
||||||
|
if (!fs.existsSync(configDir)) {
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||||
|
console.log('Mods saved to config.json');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving mods to config:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadModsFromConfig() {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
return config.installedMods || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading mods from config:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFirstLaunch() {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
if ('hasLaunchedBefore' in config) {
|
||||||
|
return !config.hasLaunchedBefore;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUserData = config.installPath || config.username || config.javaPath ||
|
||||||
|
config.chatUsername || config.userUuids ||
|
||||||
|
Object.keys(config).length > 0;
|
||||||
|
|
||||||
|
if (!hasUserData) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAsLaunched() {
|
||||||
|
saveConfig({ hasLaunchedBefore: true, firstLaunchDate: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadConfig,
|
||||||
|
saveConfig,
|
||||||
|
saveUsername,
|
||||||
|
loadUsername,
|
||||||
|
saveChatUsername,
|
||||||
|
loadChatUsername,
|
||||||
|
getUuidForUser,
|
||||||
|
saveJavaPath,
|
||||||
|
loadJavaPath,
|
||||||
|
saveInstallPath,
|
||||||
|
loadInstallPath,
|
||||||
|
saveModsToConfig,
|
||||||
|
loadModsFromConfig,
|
||||||
|
isFirstLaunch,
|
||||||
|
markAsLaunched,
|
||||||
|
CONFIG_FILE,
|
||||||
|
// Auth domain config
|
||||||
|
DEFAULT_AUTH_DOMAIN,
|
||||||
|
getAuthDomain,
|
||||||
|
getAuthServerUrl,
|
||||||
|
saveAuthDomain
|
||||||
|
};
|
||||||
197
backend/core/paths.js
Normal file
197
backend/core/paths.js
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
function getAppDir() {
|
||||||
|
const home = os.homedir();
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return path.join(home, 'AppData', 'Local', 'HytaleF2P');
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
return path.join(home, 'Library', 'Application Support', 'HytaleF2P');
|
||||||
|
} else {
|
||||||
|
return path.join(home, '.hytalef2p');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_APP_DIR = getAppDir();
|
||||||
|
|
||||||
|
function getResolvedAppDir(customPath) {
|
||||||
|
if (customPath && customPath.trim()) {
|
||||||
|
return path.join(customPath.trim(), 'HytaleF2P');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const configFile = path.join(DEFAULT_APP_DIR, 'config.json');
|
||||||
|
if (fs.existsSync(configFile)) {
|
||||||
|
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||||
|
if (config.installPath && config.installPath.trim()) {
|
||||||
|
return path.join(config.installPath.trim(), 'HytaleF2P');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
}
|
||||||
|
return DEFAULT_APP_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandHome(inputPath) {
|
||||||
|
if (!inputPath) {
|
||||||
|
return inputPath;
|
||||||
|
}
|
||||||
|
if (inputPath === '~') {
|
||||||
|
return os.homedir();
|
||||||
|
}
|
||||||
|
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
|
||||||
|
return path.join(os.homedir(), inputPath.slice(2));
|
||||||
|
}
|
||||||
|
return inputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APP_DIR = DEFAULT_APP_DIR;
|
||||||
|
const CACHE_DIR = path.join(APP_DIR, 'cache');
|
||||||
|
const TOOLS_DIR = path.join(APP_DIR, 'butler');
|
||||||
|
const GAME_DIR = path.join(APP_DIR, 'release', 'package', 'game', 'latest');
|
||||||
|
const JRE_DIR = path.join(APP_DIR, 'release', 'package', 'jre', 'latest');
|
||||||
|
const PLAYER_ID_FILE = path.join(APP_DIR, 'player_id.json');
|
||||||
|
|
||||||
|
function getClientCandidates(gameLatest) {
|
||||||
|
const candidates = [];
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
candidates.push(path.join(gameLatest, 'Client', 'HytaleClient.exe'));
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
candidates.push(path.join(gameLatest, 'Client', 'Hytale.app', 'Contents', 'MacOS', 'HytaleClient'));
|
||||||
|
candidates.push(path.join(gameLatest, 'Client', 'HytaleClient'));
|
||||||
|
} else {
|
||||||
|
candidates.push(path.join(gameLatest, 'Client', 'HytaleClient'));
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findClientPath(gameLatest) {
|
||||||
|
const candidates = getClientCandidates(gameLatest);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUserDataPath(gameLatest) {
|
||||||
|
const candidates = [];
|
||||||
|
|
||||||
|
candidates.push(path.join(gameLatest, 'Client', 'UserData'));
|
||||||
|
|
||||||
|
candidates.push(path.join(gameLatest, 'Client', 'Hytale.app', 'Contents', 'UserData'));
|
||||||
|
candidates.push(path.join(gameLatest, 'Hytale.app', 'Contents', 'UserData'));
|
||||||
|
candidates.push(path.join(gameLatest, 'UserData'));
|
||||||
|
|
||||||
|
candidates.push(path.join(gameLatest, 'Client', 'UserData'));
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultPath;
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
defaultPath = path.join(gameLatest, 'Client', 'UserData');
|
||||||
|
} else {
|
||||||
|
defaultPath = path.join(gameLatest, 'Client', 'UserData');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(defaultPath)) {
|
||||||
|
fs.mkdirSync(defaultPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUserDataRecursive(gameLatest) {
|
||||||
|
function searchDirectory(dir) {
|
||||||
|
try {
|
||||||
|
const items = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.isDirectory()) {
|
||||||
|
const fullPath = path.join(dir, item.name);
|
||||||
|
|
||||||
|
if (item.name === 'UserData') {
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = searchDirectory(fullPath);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(gameLatest)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = searchDirectory(gameLatest);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getModsPath(customInstallPath = null) {
|
||||||
|
try {
|
||||||
|
let installPath = customInstallPath;
|
||||||
|
|
||||||
|
if (!installPath) {
|
||||||
|
const configFile = path.join(DEFAULT_APP_DIR, 'config.json');
|
||||||
|
if (fs.existsSync(configFile)) {
|
||||||
|
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||||
|
installPath = config.installPath || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!installPath) {
|
||||||
|
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||||
|
installPath = path.join(localAppData, 'HytaleF2P');
|
||||||
|
} else {
|
||||||
|
installPath = path.join(installPath, 'HytaleF2P');
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameLatest = path.join(installPath, 'release', 'package', 'game', 'latest');
|
||||||
|
|
||||||
|
const userDataPath = findUserDataPath(gameLatest);
|
||||||
|
|
||||||
|
const modsPath = path.join(userDataPath, 'Mods');
|
||||||
|
const disabledModsPath = path.join(userDataPath, 'DisabledMods');
|
||||||
|
|
||||||
|
if (!fs.existsSync(modsPath)) {
|
||||||
|
fs.mkdirSync(modsPath, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(disabledModsPath)) {
|
||||||
|
fs.mkdirSync(disabledModsPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return modsPath;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting mods path:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAppDir,
|
||||||
|
getResolvedAppDir,
|
||||||
|
expandHome,
|
||||||
|
APP_DIR,
|
||||||
|
CACHE_DIR,
|
||||||
|
TOOLS_DIR,
|
||||||
|
GAME_DIR,
|
||||||
|
JRE_DIR,
|
||||||
|
PLAYER_ID_FILE,
|
||||||
|
getClientCandidates,
|
||||||
|
findClientPath,
|
||||||
|
findUserDataPath,
|
||||||
|
findUserDataRecursive,
|
||||||
|
getModsPath
|
||||||
|
};
|
||||||
2292
backend/launcher.js
2292
backend/launcher.js
File diff suppressed because it is too large
Load Diff
213
backend/logger.js
Normal file
213
backend/logger.js
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
constructor() {
|
||||||
|
this.logDir = null;
|
||||||
|
this.logFile = null;
|
||||||
|
this.maxLogSize = 10 * 1024 * 1024; // 10MB
|
||||||
|
this.maxLogFiles = 5;
|
||||||
|
this.originalConsole = {
|
||||||
|
log: console.log,
|
||||||
|
error: console.error,
|
||||||
|
warn: console.warn,
|
||||||
|
info: console.info
|
||||||
|
};
|
||||||
|
|
||||||
|
this.initializeLogDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAppDir() {
|
||||||
|
const home = os.homedir();
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return path.join(home, 'AppData', 'Local', 'HytaleF2P');
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
return path.join(home, 'Library', 'Application Support', 'HytaleF2P');
|
||||||
|
} else {
|
||||||
|
return path.join(home, '.hytalef2p');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getInstallPath() {
|
||||||
|
try {
|
||||||
|
const configFile = path.join(this.getAppDir(), 'config.json');
|
||||||
|
if (fs.existsSync(configFile)) {
|
||||||
|
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||||
|
if (config.installPath && config.installPath.trim()) {
|
||||||
|
return path.join(config.installPath.trim(), 'HytaleF2P');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
}
|
||||||
|
return this.getAppDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeLogDirectory() {
|
||||||
|
try {
|
||||||
|
const installPath = this.getInstallPath();
|
||||||
|
this.logDir = path.join(installPath, 'logs');
|
||||||
|
|
||||||
|
if (!fs.existsSync(this.logDir)) {
|
||||||
|
fs.mkdirSync(this.logDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const dateString = today.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
|
const timeString = today.toISOString().split('T')[1].split('.')[0].replace(/:/g, '-'); // HH-MM-SS
|
||||||
|
this.logFile = path.join(this.logDir, `launcher-${dateString}-${timeString}.log`);
|
||||||
|
|
||||||
|
this.writeToFile(`\n=== NEW LAUNCHER SESSION - ${today.toISOString()} ===\n`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.logDir = path.join(os.tmpdir(), 'HytaleF2P-logs');
|
||||||
|
if (!fs.existsSync(this.logDir)) {
|
||||||
|
fs.mkdirSync(this.logDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const today = new Date();
|
||||||
|
const dateString = today.toISOString().split('T')[0];
|
||||||
|
const timeString = today.toISOString().split('T')[1].split('.')[0].replace(/:/g, '-');
|
||||||
|
this.logFile = path.join(this.logDir, `launcher-${dateString}-${timeString}.log`);
|
||||||
|
this.writeToFile(`\n=== FALLBACK SESSION IN TEMP - ${today.toISOString()} ===\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeToFile(message) {
|
||||||
|
if (!this.logFile) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(this.logFile)) {
|
||||||
|
const stats = fs.statSync(this.logFile);
|
||||||
|
if (stats.size > this.maxLogSize) {
|
||||||
|
this.rotateLogFile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.appendFileSync(this.logFile, message, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
this.originalConsole.error('Impossible d\'écrire dans le fichier de log:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rotateLogFile() {
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
const dateString = today.toISOString().split('T')[0];
|
||||||
|
const timeString = today.toISOString().split('T')[1].split('.')[0].replace(/:/g, '-');
|
||||||
|
|
||||||
|
const rotatedFile = path.join(this.logDir, `launcher-${dateString}-${timeString}.log`);
|
||||||
|
fs.renameSync(this.logFile, rotatedFile);
|
||||||
|
|
||||||
|
this.cleanupOldLogs();
|
||||||
|
|
||||||
|
const newToday = new Date();
|
||||||
|
const newDateString = newToday.toISOString().split('T')[0];
|
||||||
|
const newTimeString = newToday.toISOString().split('T')[1].split('.')[0].replace(/:/g, '-');
|
||||||
|
this.logFile = path.join(this.logDir, `launcher-${newDateString}-${newTimeString}.log`);
|
||||||
|
this.writeToFile(`\n=== LOG ROTATION - ${newToday.toISOString()} ===\n`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.originalConsole.error('Erreur lors de la rotation des logs:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupOldLogs() {
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(this.logDir)
|
||||||
|
.filter(file => file.startsWith('launcher-') && file.endsWith('.log'))
|
||||||
|
.map(file => ({
|
||||||
|
name: file,
|
||||||
|
path: path.join(this.logDir, file),
|
||||||
|
mtime: fs.statSync(path.join(this.logDir, file)).mtime
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
|
||||||
|
if (files.length > this.maxLogFiles) {
|
||||||
|
const filesToDelete = files.slice(this.maxLogFiles);
|
||||||
|
filesToDelete.forEach(file => {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
} catch (err) {
|
||||||
|
this.originalConsole.error(`Impossible de supprimer le fichier de log ${file.name}:`, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.originalConsole.error('Erreur lors du nettoyage des logs:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatLogMessage(level, ...args) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const message = args.map(arg => {
|
||||||
|
if (typeof arg === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(arg, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
return String(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(arg);
|
||||||
|
}).join(' ');
|
||||||
|
|
||||||
|
return `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(...args) {
|
||||||
|
const logMessage = this.formatLogMessage('info', ...args);
|
||||||
|
this.writeToFile(logMessage);
|
||||||
|
this.originalConsole.log(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(...args) {
|
||||||
|
const logMessage = this.formatLogMessage('error', ...args);
|
||||||
|
this.writeToFile(logMessage);
|
||||||
|
this.originalConsole.error(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(...args) {
|
||||||
|
const logMessage = this.formatLogMessage('warn', ...args);
|
||||||
|
this.writeToFile(logMessage);
|
||||||
|
this.originalConsole.warn(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(...args) {
|
||||||
|
const logMessage = this.formatLogMessage('info', ...args);
|
||||||
|
this.writeToFile(logMessage);
|
||||||
|
this.originalConsole.info(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
interceptConsole() {
|
||||||
|
console.log = (...args) => this.log(...args);
|
||||||
|
console.error = (...args) => this.error(...args);
|
||||||
|
console.warn = (...args) => this.warn(...args);
|
||||||
|
console.info = (...args) => this.info(...args);
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
this.error('Uncaught exception:', error.stack || error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
this.error('Unhandled rejection at', promise, 'reason:', reason);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreConsole() {
|
||||||
|
console.log = this.originalConsole.log;
|
||||||
|
console.error = this.originalConsole.error;
|
||||||
|
console.warn = this.originalConsole.warn;
|
||||||
|
console.info = this.originalConsole.info;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLogDirectory() {
|
||||||
|
return this.logDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInstallPath() {
|
||||||
|
this.initializeLogDirectory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
75
backend/managers/butlerManager.js
Normal file
75
backend/managers/butlerManager.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const AdmZip = require('adm-zip');
|
||||||
|
const { TOOLS_DIR } = require('../core/paths');
|
||||||
|
const { getOS, getArch } = require('../utils/platformUtils');
|
||||||
|
const { downloadFile } = require('../utils/fileManager');
|
||||||
|
|
||||||
|
async function installButler(toolsDir = TOOLS_DIR) {
|
||||||
|
if (!fs.existsSync(toolsDir)) {
|
||||||
|
fs.mkdirSync(toolsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const butlerName = process.platform === 'win32' ? 'butler.exe' : 'butler';
|
||||||
|
const butlerPath = path.join(toolsDir, butlerName);
|
||||||
|
const zipPath = path.join(toolsDir, 'butler.zip');
|
||||||
|
|
||||||
|
if (fs.existsSync(butlerPath)) {
|
||||||
|
return butlerPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
let urls = [];
|
||||||
|
const osName = getOS();
|
||||||
|
const arch = getArch();
|
||||||
|
if (osName === 'windows') {
|
||||||
|
urls = ['https://broth.itch.zone/butler/windows-amd64/LATEST/archive/default'];
|
||||||
|
} else if (osName === 'darwin') {
|
||||||
|
if (arch === 'arm64') {
|
||||||
|
urls = [
|
||||||
|
'https://broth.itch.zone/butler/darwin-arm64/LATEST/archive/default',
|
||||||
|
'https://broth.itch.zone/butler/darwin-amd64/LATEST/archive/default'
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
urls = ['https://broth.itch.zone/butler/darwin-amd64/LATEST/archive/default'];
|
||||||
|
}
|
||||||
|
} else if (osName === 'linux') {
|
||||||
|
urls = ['https://broth.itch.zone/butler/linux-amd64/LATEST/archive/default'];
|
||||||
|
} else {
|
||||||
|
throw new Error('Operating system not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Fetching Butler tool...');
|
||||||
|
let lastError = null;
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
await downloadFile(url, zipPath);
|
||||||
|
lastError = null;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastError) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Unpacking Butler...');
|
||||||
|
const zip = new AdmZip(zipPath);
|
||||||
|
zip.extractAllTo(toolsDir, true);
|
||||||
|
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
fs.chmodSync(butlerPath, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(zipPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Notice: could not delete butler.zip');
|
||||||
|
}
|
||||||
|
|
||||||
|
return butlerPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
installButler
|
||||||
|
};
|
||||||
406
backend/managers/gameLauncher.js
Normal file
406
backend/managers/gameLauncher.js
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { getResolvedAppDir, findClientPath } = require('../core/paths');
|
||||||
|
const { setupWaylandEnvironment } = require('../utils/platformUtils');
|
||||||
|
const { saveUsername, saveInstallPath, loadJavaPath, getUuidForUser, getAuthServerUrl, getAuthDomain } = require('../core/config');
|
||||||
|
const { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA_EXECUTABLE } = require('./javaManager');
|
||||||
|
const { getInstalledClientVersion, getLatestClientVersion } = require('../services/versionManager');
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const customAppDir = getResolvedAppDir(installPathOverride);
|
||||||
|
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
|
||||||
|
const customJreDir = path.join(customAppDir, 'release', 'package', 'jre', 'latest');
|
||||||
|
const userDataDir = path.join(customGameDir, 'Client', 'UserData');
|
||||||
|
|
||||||
|
const gameLatest = customGameDir;
|
||||||
|
let clientPath = findClientPath(gameLatest);
|
||||||
|
|
||||||
|
if (!clientPath) {
|
||||||
|
throw new Error('Game is not installed. Please install the game first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
saveUsername(playerName);
|
||||||
|
if (installPathOverride) {
|
||||||
|
saveInstallPath(installPathOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredJava = (javaPathOverride !== undefined && javaPathOverride !== null
|
||||||
|
? javaPathOverride
|
||||||
|
: loadJavaPath() || '').trim();
|
||||||
|
let javaBin = null;
|
||||||
|
|
||||||
|
if (configuredJava) {
|
||||||
|
javaBin = await resolveJavaPath(configuredJava);
|
||||||
|
if (!javaBin) {
|
||||||
|
throw new Error(`Configured Java path not found: ${configuredJava}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
javaBin = getJavaExec(customJreDir);
|
||||||
|
|
||||||
|
if (!getBundledJavaPath(customJreDir)) {
|
||||||
|
const fallback = await detectSystemJava();
|
||||||
|
if (fallback) {
|
||||||
|
javaBin = fallback;
|
||||||
|
} else {
|
||||||
|
throw new Error('Java runtime not found. Please install the game first or configure Java path.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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') {
|
||||||
|
try {
|
||||||
|
const appBundle = path.join(gameLatest, 'Client', 'Hytale.app');
|
||||||
|
const serverDir = path.join(gameLatest, 'Server');
|
||||||
|
|
||||||
|
const signPath = async (targetPath, deep = false) => {
|
||||||
|
await execAsync(`xattr -cr "${targetPath}"`).catch(() => {});
|
||||||
|
const deepFlag = deep ? '--deep ' : '';
|
||||||
|
await execAsync(`codesign --force ${deepFlag}--sign - "${targetPath}"`).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fs.existsSync(appBundle)) {
|
||||||
|
await signPath(appBundle, true);
|
||||||
|
console.log('Signed macOS app bundle (after patching)');
|
||||||
|
} else {
|
||||||
|
await signPath(path.dirname(clientPath), true);
|
||||||
|
console.log('Signed macOS client binary (after patching)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (javaBin && fs.existsSync(javaBin)) {
|
||||||
|
let jreRoot = path.dirname(path.dirname(javaBin));
|
||||||
|
if (jreRoot.endsWith('Home')) {
|
||||||
|
jreRoot = path.dirname(path.dirname(jreRoot));
|
||||||
|
}
|
||||||
|
await signPath(jreRoot, true);
|
||||||
|
await signPath(javaBin, false);
|
||||||
|
console.log('Signed Java runtime');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(serverDir)) {
|
||||||
|
await execAsync(`xattr -cr "${serverDir}"`).catch(() => {});
|
||||||
|
await execAsync(`find "${serverDir}" -type f -perm +111 -exec codesign --force --sign - {} \\;`).catch(() => {});
|
||||||
|
console.log('Signed server binaries (after patching)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (javaBin && fs.existsSync(javaBin)) {
|
||||||
|
const javaWrapperPath = path.join(path.dirname(javaBin), 'java-wrapper');
|
||||||
|
const wrapperScript = `#!/bin/bash
|
||||||
|
# Java wrapper for macOS - adds --disable-sentry to fix Sentry hang issue
|
||||||
|
REAL_JAVA="${javaBin}"
|
||||||
|
ARGS=("$@")
|
||||||
|
for i in "\${!ARGS[@]}"; do
|
||||||
|
if [[ "\${ARGS[$i]}" == *"HytaleServer.jar"* ]]; then
|
||||||
|
ARGS=("\${ARGS[@]:0:$((i+1))}" "--disable-sentry" "\${ARGS[@]:$((i+1))}")
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exec "$REAL_JAVA" "\${ARGS[@]}"
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(javaWrapperPath, wrapperScript, { mode: 0o755 });
|
||||||
|
await signPath(javaWrapperPath, false);
|
||||||
|
console.log('Created java wrapper with --disable-sentry fix');
|
||||||
|
javaBin = javaWrapperPath;
|
||||||
|
}
|
||||||
|
} catch (signError) {
|
||||||
|
console.log('Notice: macOS signing step failed:', signError.message);
|
||||||
|
console.log('The game may still launch if Gatekeeper allows it');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'--app-dir', gameLatest,
|
||||||
|
'--java-exec', javaBin,
|
||||||
|
'--auth-mode', 'authenticated',
|
||||||
|
'--uuid', uuid,
|
||||||
|
'--name', playerName,
|
||||||
|
'--identity-token', identityToken,
|
||||||
|
'--session-token', sessionToken,
|
||||||
|
'--user-dir', userDataDir
|
||||||
|
];
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Starting game...', null, null, null, null);
|
||||||
|
}
|
||||||
|
console.log('Starting game...');
|
||||||
|
console.log(`Command: "${clientPath}" ${args.join(' ')}`);
|
||||||
|
|
||||||
|
const env = { ...process.env };
|
||||||
|
|
||||||
|
const waylandEnv = setupWaylandEnvironment();
|
||||||
|
Object.assign(env, waylandEnv);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let spawnOptions = {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: true,
|
||||||
|
env: env
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
spawnOptions.shell = false;
|
||||||
|
spawnOptions.windowsHide = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(clientPath, args, spawnOptions);
|
||||||
|
|
||||||
|
console.log(`Game process started with PID: ${child.pid}`);
|
||||||
|
|
||||||
|
let hasExited = false;
|
||||||
|
let outputReceived = false;
|
||||||
|
|
||||||
|
child.stdout.on('data', (data) => {
|
||||||
|
outputReceived = true;
|
||||||
|
console.log(`Game output: ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (data) => {
|
||||||
|
outputReceived = true;
|
||||||
|
console.error(`Game error: ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
hasExited = true;
|
||||||
|
console.error(`Failed to start game process: ${error.message}`);
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback(`Failed to start game: ${error.message}`, -1, null, null, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
hasExited = true;
|
||||||
|
if (code !== null) {
|
||||||
|
console.log(`Game process exited with code ${code}`);
|
||||||
|
if (code !== 0 && progressCallback) {
|
||||||
|
progressCallback(`Game exited with error code ${code}`, -1, null, null, null);
|
||||||
|
}
|
||||||
|
} else if (signal) {
|
||||||
|
console.log(`Game process terminated by signal ${signal}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!hasExited) {
|
||||||
|
console.log('Game appears to be running successfully');
|
||||||
|
child.unref();
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Game launched successfully', 100, null, null, null);
|
||||||
|
}
|
||||||
|
} else if (!outputReceived) {
|
||||||
|
console.warn('Game process exited immediately with no output - possible issue with game files or dependencies');
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return { success: true, installed: true, launched: true, pid: child.pid };
|
||||||
|
} catch (spawnError) {
|
||||||
|
console.error(`Error spawning game process: ${spawnError.message}`);
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback(`Error launching game: ${spawnError.message}`, -1, null, null, null);
|
||||||
|
}
|
||||||
|
throw spawnError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchGameWithVersionCheck(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride) {
|
||||||
|
try {
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Checking for updates...', 0, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [installedVersion, latestVersion] = await Promise.all([
|
||||||
|
getInstalledClientVersion(),
|
||||||
|
getLatestClientVersion()
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(`Installed version: ${installedVersion}, Latest version: ${latestVersion}`);
|
||||||
|
|
||||||
|
let needsUpdate = false;
|
||||||
|
if (installedVersion && latestVersion && installedVersion !== latestVersion) {
|
||||||
|
needsUpdate = true;
|
||||||
|
console.log('Version mismatch detected, update required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsUpdate) {
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Game update required, starting update process...', 10, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const customAppDir = getResolvedAppDir(installPathOverride);
|
||||||
|
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
|
||||||
|
const customToolsDir = path.join(customAppDir, 'butler');
|
||||||
|
const customCacheDir = path.join(customAppDir, 'cache');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateGameFiles(latestVersion, progressCallback, customGameDir, customToolsDir, customCacheDir);
|
||||||
|
console.log('Game updated successfully, waiting before launch...');
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Preparing game launch...', 90, null, null, null);
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
|
} catch (updateError) {
|
||||||
|
console.error('Update failed:', updateError);
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback(`Update failed: ${updateError.message}`, -1, null, null, null);
|
||||||
|
}
|
||||||
|
throw updateError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Launching game...', 80, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await launchGame(playerName, progressCallback, javaPathOverride, installPathOverride);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in version check and launch:', error);
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback(`Error: ${error.message}`, -1, null, null, null);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
launchGame,
|
||||||
|
launchGameWithVersionCheck
|
||||||
|
};
|
||||||
406
backend/managers/gameManager.js
Normal file
406
backend/managers/gameManager.js
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursive, GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
|
||||||
|
const { getOS, getArch } = require('../utils/platformUtils');
|
||||||
|
const { downloadFile } = require('../utils/fileManager');
|
||||||
|
const { getLatestClientVersion, getInstalledClientVersion } = require('../services/versionManager');
|
||||||
|
const { installButler } = require('./butlerManager');
|
||||||
|
const { checkAndInstallMultiClient } = require('./multiClientManager');
|
||||||
|
const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFileManager');
|
||||||
|
const { saveUsername, saveInstallPath, loadJavaPath, CONFIG_FILE, loadConfig } = require('../core/config');
|
||||||
|
const { resolveJavaPath, detectSystemJava, downloadJRE, getJavaExec, getBundledJavaPath } = require('./javaManager');
|
||||||
|
|
||||||
|
async function downloadPWR(version = 'release', fileName = '4.pwr', progressCallback, cacheDir = CACHE_DIR) {
|
||||||
|
const osName = getOS();
|
||||||
|
const arch = getArch();
|
||||||
|
const url = `https://game-patches.hytale.com/patches/${osName}/${arch}/${version}/0/${fileName}`;
|
||||||
|
|
||||||
|
const dest = path.join(cacheDir, fileName);
|
||||||
|
|
||||||
|
if (fs.existsSync(dest)) {
|
||||||
|
console.log('PWR file found in cache:', dest);
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Fetching PWR patch file:', url);
|
||||||
|
await downloadFile(url, dest, progressCallback);
|
||||||
|
console.log('PWR saved to:', dest);
|
||||||
|
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR) {
|
||||||
|
const butlerPath = await installButler(toolsDir);
|
||||||
|
const gameLatest = gameDir;
|
||||||
|
const stagingDir = path.join(gameLatest, 'staging-temp');
|
||||||
|
|
||||||
|
const clientPath = findClientPath(gameLatest);
|
||||||
|
|
||||||
|
if (clientPath) {
|
||||||
|
console.log('Game files detected, skipping patch installation.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(gameLatest)) {
|
||||||
|
fs.mkdirSync(gameLatest, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(stagingDir)) {
|
||||||
|
fs.mkdirSync(stagingDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Installing game patch...', null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Installing game patch...');
|
||||||
|
|
||||||
|
if (!fs.existsSync(butlerPath)) {
|
||||||
|
throw new Error(`Butler tool not found at: ${butlerPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(pwrFile)) {
|
||||||
|
throw new Error(`PWR file not found at: ${pwrFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'apply',
|
||||||
|
'--staging-dir',
|
||||||
|
stagingDir,
|
||||||
|
pwrFile,
|
||||||
|
gameLatest
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const child = execFile(butlerPath, args, {
|
||||||
|
maxBuffer: 1024 * 1024 * 10,
|
||||||
|
timeout: 600000
|
||||||
|
}, (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('Butler stderr:', stderr);
|
||||||
|
console.error('Butler stdout:', stdout);
|
||||||
|
reject(new Error(`Patch installation failed: ${error.message}${stderr ? '\n' + stderr : ''}`));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(stagingDir)) {
|
||||||
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Installation complete', null, null, null, null);
|
||||||
|
}
|
||||||
|
console.log('Installation complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR, cacheDir = CACHE_DIR) {
|
||||||
|
let tempUpdateDir;
|
||||||
|
try {
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Updating game files...', 0, null, null, null);
|
||||||
|
}
|
||||||
|
console.log(`Updating game files to version: ${newVersion}`);
|
||||||
|
|
||||||
|
tempUpdateDir = path.join(gameDir, '..', 'temp_update');
|
||||||
|
|
||||||
|
if (fs.existsSync(tempUpdateDir)) {
|
||||||
|
fs.rmSync(tempUpdateDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
fs.mkdirSync(tempUpdateDir, { recursive: true });
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Downloading new game version...', 10, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pwrFile = await downloadPWR('release', newVersion, progressCallback, cacheDir);
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Extracting new files...', 50, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir);
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Replacing game files...', 80, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
let userDataBackup = null;
|
||||||
|
const userDataPath = findUserDataRecursive(gameDir);
|
||||||
|
|
||||||
|
if (userDataPath && fs.existsSync(userDataPath)) {
|
||||||
|
userDataBackup = path.join(gameDir, '..', 'UserData_backup_' + Date.now());
|
||||||
|
console.log(`Backing up UserData from ${userDataPath} to: ${userDataBackup}`);
|
||||||
|
|
||||||
|
function copyRecursive(src, dest) {
|
||||||
|
const stat = fs.statSync(src);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
if (!fs.existsSync(dest)) {
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
}
|
||||||
|
const files = fs.readdirSync(src);
|
||||||
|
for (const file of files) {
|
||||||
|
copyRecursive(path.join(src, file), path.join(dest, file));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copyRecursive(userDataPath, userDataBackup);
|
||||||
|
} else {
|
||||||
|
console.log('No UserData folder found in game directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(gameDir)) {
|
||||||
|
console.log('Removing old game files...');
|
||||||
|
fs.rmSync(gameDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(tempUpdateDir, gameDir);
|
||||||
|
|
||||||
|
const multiResult = await checkAndInstallMultiClient(gameDir, progressCallback);
|
||||||
|
console.log('Multiplayer-client check result after update:', multiResult);
|
||||||
|
|
||||||
|
const homeUIResult = await downloadAndReplaceHomePageUI(gameDir, progressCallback);
|
||||||
|
console.log('HomePage.ui update result after update:', homeUIResult);
|
||||||
|
|
||||||
|
const logoResult = await downloadAndReplaceLogo(gameDir, progressCallback);
|
||||||
|
console.log('Logo@2x.png update result after update:', logoResult);
|
||||||
|
|
||||||
|
if (userDataBackup && fs.existsSync(userDataBackup)) {
|
||||||
|
const newUserDataPath = findUserDataPath(gameDir);
|
||||||
|
const userDataParent = path.dirname(newUserDataPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(userDataParent)) {
|
||||||
|
fs.mkdirSync(userDataParent, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Restoring UserData to: ${newUserDataPath}`);
|
||||||
|
|
||||||
|
function copyRecursive(src, dest) {
|
||||||
|
const stat = fs.statSync(src);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
if (!fs.existsSync(dest)) {
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
}
|
||||||
|
const files = fs.readdirSync(src);
|
||||||
|
for (const file of files) {
|
||||||
|
copyRecursive(path.join(src, file), path.join(dest, file));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copyRecursive(userDataBackup, newUserDataPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Game files updated successfully to version: ${newVersion}`);
|
||||||
|
|
||||||
|
if (userDataBackup && fs.existsSync(userDataBackup)) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(userDataBackup, { recursive: true, force: true });
|
||||||
|
console.log('UserData backup cleaned up');
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Could not clean up UserData backup:', cleanupError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Waiting for file system sync...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Game update completed', 100, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, updated: true, version: newVersion };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating game files:', error);
|
||||||
|
|
||||||
|
if (userDataBackup && fs.existsSync(userDataBackup)) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(userDataBackup, { recursive: true, force: true });
|
||||||
|
console.log('UserData backup cleaned up after error');
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Could not clean up UserData backup:', cleanupError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempUpdateDir && fs.existsSync(tempUpdateDir)) {
|
||||||
|
fs.rmSync(tempUpdateDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Failed to update game files: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGameInstalled() {
|
||||||
|
const appDir = getResolvedAppDir();
|
||||||
|
const gameDir = path.join(appDir, 'release', 'package', 'game', 'latest');
|
||||||
|
const clientPath = findClientPath(gameDir);
|
||||||
|
return clientPath !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride) {
|
||||||
|
const customAppDir = getResolvedAppDir(installPathOverride);
|
||||||
|
const customCacheDir = path.join(customAppDir, 'cache');
|
||||||
|
const customToolsDir = path.join(customAppDir, 'butler');
|
||||||
|
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
|
||||||
|
const customJreDir = path.join(customAppDir, 'release', 'package', 'jre', 'latest');
|
||||||
|
const userDataDir = path.join(customGameDir, 'Client', 'UserData');
|
||||||
|
|
||||||
|
[customAppDir, customCacheDir, customToolsDir].forEach(dir => {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fs.existsSync(userDataDir)) {
|
||||||
|
fs.mkdirSync(userDataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
saveUsername(playerName);
|
||||||
|
if (installPathOverride) {
|
||||||
|
saveInstallPath(installPathOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameLatest = customGameDir;
|
||||||
|
let clientPath = findClientPath(gameLatest);
|
||||||
|
|
||||||
|
if (clientPath) {
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Game already installed', 100, null, null, null);
|
||||||
|
}
|
||||||
|
console.log('Game is already installed');
|
||||||
|
return { success: true, alreadyInstalled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredJava = (javaPathOverride !== undefined && javaPathOverride !== null
|
||||||
|
? javaPathOverride
|
||||||
|
: loadJavaPath() || '').trim();
|
||||||
|
let javaBin = null;
|
||||||
|
|
||||||
|
if (configuredJava) {
|
||||||
|
javaBin = await resolveJavaPath(configuredJava);
|
||||||
|
if (!javaBin) {
|
||||||
|
throw new Error(`Configured Java path not found: ${configuredJava}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await downloadJRE(progressCallback, customCacheDir, customJreDir);
|
||||||
|
} catch (error) {
|
||||||
|
const fallback = await detectSystemJava();
|
||||||
|
if (fallback) {
|
||||||
|
javaBin = fallback;
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!javaBin) {
|
||||||
|
javaBin = getJavaExec(customJreDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Fetching game files...', null, null, null, null);
|
||||||
|
}
|
||||||
|
console.log('Installing game files...');
|
||||||
|
|
||||||
|
const latestVersion = await getLatestClientVersion();
|
||||||
|
const pwrFile = await downloadPWR('release', latestVersion, progressCallback, customCacheDir);
|
||||||
|
await applyPWR(pwrFile, progressCallback, customGameDir, customToolsDir);
|
||||||
|
|
||||||
|
const multiResult = await checkAndInstallMultiClient(customGameDir, progressCallback);
|
||||||
|
console.log('Multiplayer check result:', multiResult);
|
||||||
|
|
||||||
|
const homeUIResult = await downloadAndReplaceHomePageUI(customGameDir, progressCallback);
|
||||||
|
console.log('HomePage.ui update result after installation:', homeUIResult);
|
||||||
|
|
||||||
|
const logoResult = await downloadAndReplaceLogo(customGameDir, progressCallback);
|
||||||
|
console.log('Logo@2x.png update result after installation:', logoResult);
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Installation complete', 100, null, null, null);
|
||||||
|
}
|
||||||
|
console.log('Game installation completed successfully');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
installed: true,
|
||||||
|
multiClient: multiResult
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uninstallGame() {
|
||||||
|
const appDir = getResolvedAppDir();
|
||||||
|
|
||||||
|
if (!fs.existsSync(appDir)) {
|
||||||
|
throw new Error('Game is not installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.rmSync(appDir, { recursive: true, force: true });
|
||||||
|
console.log('Game uninstalled successfully - removed entire HytaleF2P folder');
|
||||||
|
|
||||||
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
|
const config = loadConfig();
|
||||||
|
delete config.installPath;
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to uninstall game: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkExistingGameInstallation() {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
if (!config.installPath || !config.installPath.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installPath = config.installPath.trim();
|
||||||
|
const gameDir = path.join(installPath, 'HytaleF2P', 'release', 'package', 'game', 'latest');
|
||||||
|
|
||||||
|
if (!fs.existsSync(gameDir)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientPath = findClientPath(gameDir);
|
||||||
|
if (!clientPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDataPath = findUserDataRecursive(gameDir);
|
||||||
|
|
||||||
|
return {
|
||||||
|
gameDir: gameDir,
|
||||||
|
clientPath: clientPath,
|
||||||
|
userDataPath: userDataPath,
|
||||||
|
installPath: installPath,
|
||||||
|
hasUserData: userDataPath && fs.existsSync(userDataPath)
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking existing game installation:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
downloadPWR,
|
||||||
|
applyPWR,
|
||||||
|
updateGameFiles,
|
||||||
|
isGameInstalled,
|
||||||
|
installGame,
|
||||||
|
uninstallGame,
|
||||||
|
checkExistingGameInstallation
|
||||||
|
};
|
||||||
363
backend/managers/javaManager.js
Normal file
363
backend/managers/javaManager.js
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const axios = require('axios');
|
||||||
|
const AdmZip = require('adm-zip');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const tar = require('tar');
|
||||||
|
const { expandHome, JRE_DIR } = require('../core/paths');
|
||||||
|
const { getOS, getArch } = require('../utils/platformUtils');
|
||||||
|
const { loadConfig } = require('../core/config');
|
||||||
|
const { downloadFile } = require('../utils/fileManager');
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const JAVA_EXECUTABLE = 'java' + (process.platform === 'win32' ? '.exe' : '');
|
||||||
|
|
||||||
|
async function findJavaOnPath(commandName = 'java') {
|
||||||
|
const lookupCmd = process.platform === 'win32' ? 'where' : 'which';
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(lookupCmd, [commandName]);
|
||||||
|
const line = stdout.split(/\r?\n/).map(lineItem => lineItem.trim()).find(Boolean);
|
||||||
|
return line || null;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMacJavaHome() {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('/usr/libexec/java_home');
|
||||||
|
const home = stdout.trim();
|
||||||
|
if (!home) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path.join(home, 'bin', JAVA_EXECUTABLE);
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveJavaPath(inputPath) {
|
||||||
|
const trimmed = (inputPath || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expanded = expandHome(trimmed);
|
||||||
|
if (fs.existsSync(expanded)) {
|
||||||
|
const stat = fs.statSync(expanded);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const candidate = path.join(expanded, 'bin', JAVA_EXECUTABLE);
|
||||||
|
return fs.existsSync(candidate) ? candidate : null;
|
||||||
|
}
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path.isAbsolute(expanded)) {
|
||||||
|
return await findJavaOnPath(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectSystemJava() {
|
||||||
|
const envHome = process.env.JAVA_HOME;
|
||||||
|
if (envHome) {
|
||||||
|
const envJava = path.join(envHome, 'bin', JAVA_EXECUTABLE);
|
||||||
|
if (fs.existsSync(envJava)) {
|
||||||
|
return envJava;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const macJava = await getMacJavaHome();
|
||||||
|
if (macJava && fs.existsSync(macJava)) {
|
||||||
|
return macJava;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathJava = await findJavaOnPath('java');
|
||||||
|
if (pathJava && fs.existsSync(pathJava)) {
|
||||||
|
return pathJava;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadJavaPath() {
|
||||||
|
const config = loadConfig();
|
||||||
|
return config.javaPath || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBundledJavaPath(jreDir = JRE_DIR) {
|
||||||
|
const candidates = [
|
||||||
|
path.join(jreDir, 'bin', JAVA_EXECUTABLE)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
candidates.push(path.join(jreDir, 'Contents', 'Home', 'bin', JAVA_EXECUTABLE));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJavaExec(jreDir = JRE_DIR) {
|
||||||
|
const bundledJava = getBundledJavaPath(jreDir);
|
||||||
|
if (bundledJava) {
|
||||||
|
return bundledJava;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Notice: Java runtime not found, using system default');
|
||||||
|
return 'java';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJavaDetection() {
|
||||||
|
const candidates = [];
|
||||||
|
const bundledJava = getBundledJavaPath() || path.join(JRE_DIR, 'bin', JAVA_EXECUTABLE);
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
label: 'Bundled JRE',
|
||||||
|
path: bundledJava,
|
||||||
|
exists: fs.existsSync(bundledJava)
|
||||||
|
});
|
||||||
|
|
||||||
|
const javaHomeEnv = process.env.JAVA_HOME;
|
||||||
|
if (javaHomeEnv) {
|
||||||
|
const envJava = path.join(javaHomeEnv, 'bin', JAVA_EXECUTABLE);
|
||||||
|
candidates.push({
|
||||||
|
label: 'JAVA_HOME',
|
||||||
|
path: envJava,
|
||||||
|
exists: fs.existsSync(envJava),
|
||||||
|
note: fs.existsSync(envJava) ? '' : 'Not found'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
candidates.push({
|
||||||
|
label: 'JAVA_HOME',
|
||||||
|
path: '',
|
||||||
|
exists: false,
|
||||||
|
note: 'Not set'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const macJava = await getMacJavaHome();
|
||||||
|
if (macJava) {
|
||||||
|
candidates.push({
|
||||||
|
label: 'java_home',
|
||||||
|
path: macJava,
|
||||||
|
exists: fs.existsSync(macJava),
|
||||||
|
note: fs.existsSync(macJava) ? '' : 'Not found'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
candidates.push({
|
||||||
|
label: 'java_home',
|
||||||
|
path: '',
|
||||||
|
exists: false,
|
||||||
|
note: 'Not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathJava = await findJavaOnPath('java');
|
||||||
|
if (pathJava) {
|
||||||
|
candidates.push({
|
||||||
|
label: 'PATH',
|
||||||
|
path: pathJava,
|
||||||
|
exists: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
candidates.push({
|
||||||
|
label: 'PATH',
|
||||||
|
path: '',
|
||||||
|
exists: false,
|
||||||
|
note: 'java not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
javaPath: loadJavaPath(),
|
||||||
|
candidates
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadJRE(progressCallback, cacheDir, jreDir = JRE_DIR) {
|
||||||
|
if (!fs.existsSync(cacheDir)) {
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const osName = getOS();
|
||||||
|
const arch = getArch();
|
||||||
|
|
||||||
|
const bundledJava = getBundledJavaPath(jreDir);
|
||||||
|
if (bundledJava) {
|
||||||
|
console.log('Java runtime found, skipping download');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Requesting Java runtime information...');
|
||||||
|
const response = await axios.get('https://launcher.hytale.com/version/release/jre.json', {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const jreData = response.data;
|
||||||
|
|
||||||
|
const osData = jreData.download_url[osName];
|
||||||
|
if (!osData) {
|
||||||
|
throw new Error(`Java runtime unavailable for platform: ${osName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = osData[arch];
|
||||||
|
if (!platform) {
|
||||||
|
throw new Error(`Java runtime unavailable for architecture ${arch} on ${osName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = path.basename(platform.url);
|
||||||
|
const cacheFile = path.join(cacheDir, fileName);
|
||||||
|
|
||||||
|
if (!fs.existsSync(cacheFile)) {
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Fetching Java runtime...', null, null, null, null);
|
||||||
|
}
|
||||||
|
console.log('Fetching Java runtime...');
|
||||||
|
await downloadFile(platform.url, cacheFile, progressCallback);
|
||||||
|
console.log('Download finished');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Validating files...', null, null, null, null);
|
||||||
|
}
|
||||||
|
console.log('Validating files...');
|
||||||
|
const fileBuffer = fs.readFileSync(cacheFile);
|
||||||
|
const hashSum = crypto.createHash('sha256');
|
||||||
|
hashSum.update(fileBuffer);
|
||||||
|
const hex = hashSum.digest('hex');
|
||||||
|
|
||||||
|
if (hex !== platform.sha256) {
|
||||||
|
fs.unlinkSync(cacheFile);
|
||||||
|
throw new Error(`File validation failed: expected ${platform.sha256} but got ${hex}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Unpacking Java runtime...', null, null, null, null);
|
||||||
|
}
|
||||||
|
console.log('Unpacking Java runtime...');
|
||||||
|
await extractJRE(cacheFile, jreDir);
|
||||||
|
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
const javaCandidates = [
|
||||||
|
path.join(jreDir, 'bin', JAVA_EXECUTABLE),
|
||||||
|
path.join(jreDir, 'Contents', 'Home', 'bin', JAVA_EXECUTABLE)
|
||||||
|
];
|
||||||
|
for (const javaPath of javaCandidates) {
|
||||||
|
if (fs.existsSync(javaPath)) {
|
||||||
|
fs.chmodSync(javaPath, 0o755);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flattenJREDir(jreDir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(cacheFile);
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Notice: could not delete cached Java files:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Java runtime ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractJRE(archivePath, destDir) {
|
||||||
|
if (fs.existsSync(destDir)) {
|
||||||
|
fs.rmSync(destDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
|
||||||
|
if (archivePath.endsWith('.zip')) {
|
||||||
|
return extractZip(archivePath, destDir);
|
||||||
|
} else if (archivePath.endsWith('.tar.gz')) {
|
||||||
|
return extractTarGz(archivePath, destDir);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Archive type not supported: ${archivePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractZip(zipPath, dest) {
|
||||||
|
const zip = new AdmZip(zipPath);
|
||||||
|
const entries = zip.getEntries();
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(dest, entry.entryName);
|
||||||
|
|
||||||
|
const resolvedPath = path.resolve(entryPath);
|
||||||
|
const resolvedDest = path.resolve(dest);
|
||||||
|
if (!resolvedPath.startsWith(resolvedDest)) {
|
||||||
|
throw new Error(`Invalid file path detected: ${entryPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
fs.mkdirSync(entryPath, { recursive: true });
|
||||||
|
} else {
|
||||||
|
fs.mkdirSync(path.dirname(entryPath), { recursive: true });
|
||||||
|
fs.writeFileSync(entryPath, entry.getData());
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
fs.chmodSync(entryPath, entry.header.attr >>> 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTarGz(tarGzPath, dest) {
|
||||||
|
return tar.extract({
|
||||||
|
file: tarGzPath,
|
||||||
|
cwd: dest,
|
||||||
|
strip: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenJREDir(jreLatest) {
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(jreLatest, { withFileTypes: true });
|
||||||
|
|
||||||
|
if (entries.length !== 1 || !entries[0].isDirectory()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = path.join(jreLatest, entries[0].name);
|
||||||
|
const files = fs.readdirSync(nested, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const oldPath = path.join(nested, file.name);
|
||||||
|
const newPath = path.join(jreLatest, file.name);
|
||||||
|
fs.renameSync(oldPath, newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(nested, { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Notice: could not restructure Java directory:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
findJavaOnPath,
|
||||||
|
getMacJavaHome,
|
||||||
|
resolveJavaPath,
|
||||||
|
detectSystemJava,
|
||||||
|
loadJavaPath,
|
||||||
|
getBundledJavaPath,
|
||||||
|
getJavaExec,
|
||||||
|
getJavaDetection,
|
||||||
|
downloadJRE,
|
||||||
|
extractJRE,
|
||||||
|
JAVA_EXECUTABLE
|
||||||
|
};
|
||||||
276
backend/managers/modManager.js
Normal file
276
backend/managers/modManager.js
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const axios = require('axios');
|
||||||
|
const { getModsPath } = require('../core/paths');
|
||||||
|
const { saveModsToConfig, loadModsFromConfig } = require('../core/config');
|
||||||
|
|
||||||
|
function generateModId(filename) {
|
||||||
|
return crypto.createHash('md5').update(filename).digest('hex').substring(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractModName(filename) {
|
||||||
|
let name = path.parse(filename).name;
|
||||||
|
|
||||||
|
name = name.replace(/-v?\d+\.[\d\.]+.*$/i, '');
|
||||||
|
name = name.replace(/-\d+\.[\d\.]+.*$/i, '');
|
||||||
|
|
||||||
|
name = name.replace(/[-_]/g, ' ');
|
||||||
|
name = name.replace(/\b\w/g, l => l.toUpperCase());
|
||||||
|
|
||||||
|
return name || 'Unknown Mod';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractVersion(filename) {
|
||||||
|
const versionMatch = filename.match(/v?(\d+\.[\d\.]+)/);
|
||||||
|
return versionMatch ? versionMatch[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInstalledMods(modsPath) {
|
||||||
|
try {
|
||||||
|
const configMods = loadModsFromConfig();
|
||||||
|
const modsMap = new Map();
|
||||||
|
|
||||||
|
configMods.forEach(mod => {
|
||||||
|
modsMap.set(mod.fileName, mod);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fs.existsSync(modsPath)) {
|
||||||
|
const files = fs.readdirSync(modsPath);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(modsPath, file);
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
|
||||||
|
if (stats.isFile() && (file.endsWith('.jar') || file.endsWith('.zip'))) {
|
||||||
|
const configMod = modsMap.get(file);
|
||||||
|
|
||||||
|
const modInfo = {
|
||||||
|
id: configMod?.id || generateModId(file),
|
||||||
|
name: configMod?.name || extractModName(file),
|
||||||
|
version: configMod?.version || extractVersion(file) || '1.0.0',
|
||||||
|
description: configMod?.description || 'Installed mod',
|
||||||
|
author: configMod?.author || 'Unknown',
|
||||||
|
enabled: true,
|
||||||
|
filePath: filePath,
|
||||||
|
fileName: file,
|
||||||
|
fileSize: configMod?.fileSize || stats.size,
|
||||||
|
dateInstalled: configMod?.dateInstalled || stats.birthtime || stats.mtime,
|
||||||
|
curseForgeId: configMod?.curseForgeId,
|
||||||
|
curseForgeFileId: configMod?.curseForgeFileId
|
||||||
|
};
|
||||||
|
|
||||||
|
modsMap.set(file, modInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods');
|
||||||
|
if (fs.existsSync(disabledModsPath)) {
|
||||||
|
const files = fs.readdirSync(disabledModsPath);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(disabledModsPath, file);
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
|
||||||
|
if (stats.isFile() && (file.endsWith('.jar') || file.endsWith('.zip'))) {
|
||||||
|
const configMod = modsMap.get(file);
|
||||||
|
|
||||||
|
const modInfo = {
|
||||||
|
id: configMod?.id || generateModId(file),
|
||||||
|
name: configMod?.name || extractModName(file),
|
||||||
|
version: configMod?.version || extractVersion(file) || '1.0.0',
|
||||||
|
description: configMod?.description || 'Disabled mod',
|
||||||
|
author: configMod?.author || 'Unknown',
|
||||||
|
enabled: false,
|
||||||
|
filePath: filePath,
|
||||||
|
fileName: file,
|
||||||
|
fileSize: configMod?.fileSize || stats.size,
|
||||||
|
dateInstalled: configMod?.dateInstalled || stats.birthtime || stats.mtime,
|
||||||
|
curseForgeId: configMod?.curseForgeId,
|
||||||
|
curseForgeFileId: configMod?.curseForgeFileId
|
||||||
|
};
|
||||||
|
|
||||||
|
modsMap.set(file, modInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(modsMap.values());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading installed mods:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadMod(modInfo) {
|
||||||
|
try {
|
||||||
|
const modsPath = await getModsPath();
|
||||||
|
|
||||||
|
if (!modInfo.downloadUrl && !modInfo.fileId) {
|
||||||
|
throw new Error('No download URL or file ID provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
let downloadUrl = modInfo.downloadUrl;
|
||||||
|
|
||||||
|
if (!downloadUrl && modInfo.fileId && modInfo.modId) {
|
||||||
|
const response = await axios.get(`https://api.curseforge.com/v1/mods/${modInfo.modId}/files/${modInfo.fileId}`, {
|
||||||
|
headers: {
|
||||||
|
'x-api-key': modInfo.apiKey,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadUrl = response.data.data.downloadUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!downloadUrl) {
|
||||||
|
throw new Error('Could not determine download URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = modInfo.fileName || `mod-${modInfo.modId}.jar`;
|
||||||
|
const filePath = path.join(modsPath, fileName);
|
||||||
|
|
||||||
|
const response = await axios({
|
||||||
|
method: 'get',
|
||||||
|
url: downloadUrl,
|
||||||
|
responseType: 'stream'
|
||||||
|
});
|
||||||
|
|
||||||
|
const writer = fs.createWriteStream(filePath);
|
||||||
|
response.data.pipe(writer);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
writer.on('finish', () => {
|
||||||
|
const configMods = loadModsFromConfig();
|
||||||
|
const newMod = {
|
||||||
|
id: modInfo.id || generateModId(fileName),
|
||||||
|
name: modInfo.name || extractModName(fileName),
|
||||||
|
version: modInfo.version || '1.0.0',
|
||||||
|
description: modInfo.summary || modInfo.description || 'Downloaded from CurseForge',
|
||||||
|
author: modInfo.author || 'Unknown',
|
||||||
|
enabled: true,
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: fs.statSync(filePath).size,
|
||||||
|
dateInstalled: new Date().toISOString(),
|
||||||
|
curseForgeId: modInfo.modId,
|
||||||
|
curseForgeFileId: modInfo.fileId
|
||||||
|
};
|
||||||
|
|
||||||
|
configMods.push(newMod);
|
||||||
|
saveModsToConfig(configMods);
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
filePath: filePath,
|
||||||
|
fileName: fileName,
|
||||||
|
modInfo: newMod
|
||||||
|
});
|
||||||
|
});
|
||||||
|
writer.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading mod:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uninstallMod(modId, modsPath) {
|
||||||
|
try {
|
||||||
|
const configMods = loadModsFromConfig();
|
||||||
|
const mod = configMods.find(m => m.id === modId);
|
||||||
|
|
||||||
|
if (!mod) {
|
||||||
|
throw new Error('Mod not found in config');
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods');
|
||||||
|
const enabledPath = path.join(modsPath, mod.fileName);
|
||||||
|
const disabledPath = path.join(disabledModsPath, mod.fileName);
|
||||||
|
|
||||||
|
let fileRemoved = false;
|
||||||
|
if (fs.existsSync(enabledPath)) {
|
||||||
|
fs.unlinkSync(enabledPath);
|
||||||
|
fileRemoved = true;
|
||||||
|
console.log('Removed mod from Mods folder:', enabledPath);
|
||||||
|
} else if (fs.existsSync(disabledPath)) {
|
||||||
|
fs.unlinkSync(disabledPath);
|
||||||
|
fileRemoved = true;
|
||||||
|
console.log('Removed mod from DisabledMods folder:', disabledPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileRemoved) {
|
||||||
|
console.warn('Mod file not found on filesystem, removing from config anyway');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMods = configMods.filter(m => m.id !== modId);
|
||||||
|
saveModsToConfig(updatedMods);
|
||||||
|
console.log('Mod removed from config.json');
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uninstalling mod:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMod(modId, modsPath) {
|
||||||
|
try {
|
||||||
|
const mods = await loadInstalledMods(modsPath);
|
||||||
|
const mod = mods.find(m => m.id === modId);
|
||||||
|
|
||||||
|
if (!mod) {
|
||||||
|
throw new Error('Mod not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods');
|
||||||
|
if (!fs.existsSync(disabledModsPath)) {
|
||||||
|
fs.mkdirSync(disabledModsPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPath = mod.filePath;
|
||||||
|
let newPath, newEnabled;
|
||||||
|
|
||||||
|
if (mod.enabled) {
|
||||||
|
newPath = path.join(disabledModsPath, path.basename(currentPath));
|
||||||
|
newEnabled = false;
|
||||||
|
} else {
|
||||||
|
newPath = path.join(modsPath, path.basename(currentPath));
|
||||||
|
newEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(currentPath, newPath);
|
||||||
|
|
||||||
|
const configMods = loadModsFromConfig();
|
||||||
|
const configModIndex = configMods.findIndex(m => m.id === modId);
|
||||||
|
if (configModIndex !== -1) {
|
||||||
|
configMods[configModIndex].enabled = newEnabled;
|
||||||
|
saveModsToConfig(configMods);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, enabled: newEnabled };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling mod:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadInstalledMods,
|
||||||
|
downloadMod,
|
||||||
|
uninstallMod,
|
||||||
|
toggleMod,
|
||||||
|
generateModId,
|
||||||
|
extractModName,
|
||||||
|
extractVersion
|
||||||
|
};
|
||||||
86
backend/managers/multiClientManager.js
Normal file
86
backend/managers/multiClientManager.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { findClientPath } = require('../core/paths');
|
||||||
|
const { downloadFile } = require('../utils/fileManager');
|
||||||
|
const { getLatestClientVersion, getMultiClientVersion } = require('../services/versionManager');
|
||||||
|
|
||||||
|
async function downloadMultiClient(gameDir, progressCallback) {
|
||||||
|
try {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
console.log('Multiplayer-client is only available for Windows');
|
||||||
|
return { success: false, reason: 'Platform not supported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientPath = findClientPath(gameDir);
|
||||||
|
if (!clientPath) {
|
||||||
|
throw new Error('Game client not found. Install game first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Downloading Multiplayer from server...');
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Downloading Multiplayer...', null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientUrl = 'http://3.10.208.30:3002/client';
|
||||||
|
const tempClientPath = path.join(path.dirname(clientPath), 'HytaleClient_temp.exe');
|
||||||
|
|
||||||
|
await downloadFile(clientUrl, tempClientPath, progressCallback);
|
||||||
|
|
||||||
|
const backupPath = path.join(path.dirname(clientPath), 'HytaleClient_original.exe');
|
||||||
|
if (!fs.existsSync(backupPath)) {
|
||||||
|
fs.copyFileSync(clientPath, backupPath);
|
||||||
|
console.log('Original client backed up');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(tempClientPath, clientPath);
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Multiplayer installed', 100, null, null, null);
|
||||||
|
}
|
||||||
|
console.log('Multiplayer installed successfully');
|
||||||
|
|
||||||
|
return { success: true, installed: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error installing Multiplayer:', error);
|
||||||
|
throw new Error(`Failed to install Multiplayer: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAndInstallMultiClient(gameDir, progressCallback) {
|
||||||
|
try {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
console.log('Multiplayer check skipped (Windows only)');
|
||||||
|
return { success: true, skipped: true, reason: 'Windows only' };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Checking for Multiplayer availability...');
|
||||||
|
|
||||||
|
const [clientVersion, multiVersion] = await Promise.all([
|
||||||
|
getLatestClientVersion(),
|
||||||
|
getMultiClientVersion()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!multiVersion) {
|
||||||
|
console.log('Multiplayer not available');
|
||||||
|
return { success: true, skipped: true, reason: 'Multiplayer not available' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientVersion === multiVersion) {
|
||||||
|
console.log(`Versions match (${clientVersion}), installing Multiplayer...`);
|
||||||
|
return await downloadMultiClient(gameDir, progressCallback);
|
||||||
|
} else {
|
||||||
|
console.log(`Version mismatch: client=${clientVersion}, multi=${multiVersion}`);
|
||||||
|
return { success: true, skipped: true, reason: 'Version mismatch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking Multiplayer:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
downloadMultiClient,
|
||||||
|
checkAndInstallMultiClient
|
||||||
|
};
|
||||||
116
backend/managers/uiFileManager.js
Normal file
116
backend/managers/uiFileManager.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { downloadFile, findHomePageUIPath, findLogoPath } = require('../utils/fileManager');
|
||||||
|
|
||||||
|
async function downloadAndReplaceHomePageUI(gameDir, progressCallback) {
|
||||||
|
try {
|
||||||
|
console.log('Downloading HomePage.ui from server...');
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Downloading HomePage.ui...', null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeUIUrl = 'http://3.10.208.30:3002/api/HomeUI';
|
||||||
|
const tempHomePath = path.join(path.dirname(gameDir), 'HomePage_temp.ui');
|
||||||
|
|
||||||
|
await downloadFile(homeUIUrl, tempHomePath);
|
||||||
|
|
||||||
|
const existingHomePath = findHomePageUIPath(gameDir);
|
||||||
|
|
||||||
|
if (existingHomePath && fs.existsSync(existingHomePath)) {
|
||||||
|
console.log('Found existing HomePage.ui at:', existingHomePath);
|
||||||
|
|
||||||
|
const backupPath = existingHomePath + '.backup';
|
||||||
|
if (!fs.existsSync(backupPath)) {
|
||||||
|
fs.copyFileSync(existingHomePath, backupPath);
|
||||||
|
console.log('Original HomePage.ui backed up');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.copyFileSync(tempHomePath, existingHomePath);
|
||||||
|
console.log('HomePage.ui replaced successfully');
|
||||||
|
} else {
|
||||||
|
console.log('No existing HomePage.ui found, skipping replacement');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(tempHomePath)) {
|
||||||
|
fs.unlinkSync(tempHomePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('HomePage.ui updated', null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, updated: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading/replacing HomePage.ui:', error);
|
||||||
|
|
||||||
|
const tempHomePath = path.join(path.dirname(gameDir), 'HomePage_temp.ui');
|
||||||
|
if (fs.existsSync(tempHomePath)) {
|
||||||
|
fs.unlinkSync(tempHomePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('HomePage.ui update failed, continuing...');
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAndReplaceLogo(gameDir, progressCallback) {
|
||||||
|
try {
|
||||||
|
console.log('Downloading Logo@2x.png from server...');
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Downloading Logo@2x.png...', null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoUrl = 'http://3.10.208.30:3002/api/Logo';
|
||||||
|
const tempLogoPath = path.join(path.dirname(gameDir), 'Logo@2x_temp.png');
|
||||||
|
|
||||||
|
await downloadFile(logoUrl, tempLogoPath);
|
||||||
|
|
||||||
|
const existingLogoPath = findLogoPath(gameDir);
|
||||||
|
|
||||||
|
if (existingLogoPath && fs.existsSync(existingLogoPath)) {
|
||||||
|
console.log('Found existing Logo@2x.png at:', existingLogoPath);
|
||||||
|
|
||||||
|
const backupPath = existingLogoPath + '.backup';
|
||||||
|
if (!fs.existsSync(backupPath)) {
|
||||||
|
fs.copyFileSync(existingLogoPath, backupPath);
|
||||||
|
console.log('Original Logo@2x.png backed up');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.copyFileSync(tempLogoPath, existingLogoPath);
|
||||||
|
console.log('Logo@2x.png replaced successfully');
|
||||||
|
} else {
|
||||||
|
console.log('No existing Logo@2x.png found, skipping replacement');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(tempLogoPath)) {
|
||||||
|
fs.unlinkSync(tempLogoPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Logo@2x.png updated', null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, updated: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading/replacing Logo@2x.png:', error);
|
||||||
|
|
||||||
|
const tempLogoPath = path.join(path.dirname(gameDir), 'Logo@2x_temp.png');
|
||||||
|
if (fs.existsSync(tempLogoPath)) {
|
||||||
|
fs.unlinkSync(tempLogoPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Logo@2x.png update failed, continuing...');
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
downloadAndReplaceHomePageUI,
|
||||||
|
findHomePageUIPath,
|
||||||
|
downloadAndReplaceLogo,
|
||||||
|
findLogoPath
|
||||||
|
};
|
||||||
105
backend/services/firstLaunch.js
Normal file
105
backend/services/firstLaunch.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { markAsLaunched, loadConfig } = require('../core/config');
|
||||||
|
const { checkExistingGameInstallation, updateGameFiles } = require('../managers/gameManager');
|
||||||
|
const { getInstalledClientVersion, getLatestClientVersion } = require('./versionManager');
|
||||||
|
|
||||||
|
async function proposeGameUpdate(existingGame, progressCallback) {
|
||||||
|
try {
|
||||||
|
console.log('Proposing game update for existing installation...');
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Checking for game updates...', 0, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [installedVersion, latestVersion] = await Promise.all([
|
||||||
|
getInstalledClientVersion(),
|
||||||
|
getLatestClientVersion()
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(`Existing installation - Installed: ${installedVersion}, Latest: ${latestVersion}`);
|
||||||
|
|
||||||
|
const customAppDir = path.join(existingGame.installPath, 'HytaleF2P');
|
||||||
|
const customCacheDir = path.join(customAppDir, 'cache');
|
||||||
|
const customToolsDir = path.join(customAppDir, 'butler');
|
||||||
|
|
||||||
|
[customCacheDir, customToolsDir].forEach(dir => {
|
||||||
|
const fs = require('fs');
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Updating existing game installation...', 20, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateGameFiles(latestVersion, progressCallback, existingGame.gameDir, customToolsDir, customCacheDir);
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Game update completed successfully', 100, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Existing game installation updated successfully');
|
||||||
|
return { success: true, updated: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating existing game:', error);
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback(`Update failed: ${error.message}`, -1, null, null, null);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFirstLaunchCheck(progressCallback) {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
if (config.hasLaunchedBefore === true) {
|
||||||
|
return { isFirstLaunch: false, needsUpdate: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('First launch detected, checking for existing game installation...');
|
||||||
|
|
||||||
|
const existingGame = checkExistingGameInstallation();
|
||||||
|
|
||||||
|
if (!existingGame) {
|
||||||
|
console.log('No existing game installation found');
|
||||||
|
|
||||||
|
const hasUserData = config.installPath || config.username || config.javaPath ||
|
||||||
|
config.chatUsername || config.userUuids ||
|
||||||
|
Object.keys(config).length > 0;
|
||||||
|
|
||||||
|
if (hasUserData) {
|
||||||
|
console.log('Detected existing user data but no game, marking as launched');
|
||||||
|
markAsLaunched();
|
||||||
|
return { isFirstLaunch: false, needsUpdate: false };
|
||||||
|
} else {
|
||||||
|
markAsLaunched();
|
||||||
|
return { isFirstLaunch: true, needsUpdate: false, existingGame: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Existing game installation found:', {
|
||||||
|
gameDir: existingGame.gameDir,
|
||||||
|
hasUserData: existingGame.hasUserData
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isFirstLaunch: true,
|
||||||
|
needsUpdate: true,
|
||||||
|
existingGame: existingGame
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in first launch check:', error);
|
||||||
|
markAsLaunched();
|
||||||
|
return { isFirstLaunch: true, needsUpdate: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
proposeGameUpdate,
|
||||||
|
handleFirstLaunchCheck
|
||||||
|
};
|
||||||
31
backend/services/newsManager.js
Normal file
31
backend/services/newsManager.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function getHytaleNews() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('https://launcher.hytale.com/launcher-feed/release/feed.json', {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
|
},
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
const articles = response.data.articles || [];
|
||||||
|
return articles.map(article => ({
|
||||||
|
title: article.title || '',
|
||||||
|
description: article.description || '',
|
||||||
|
destUrl: article.dest_url || '',
|
||||||
|
imageUrl: article.image_url ?
|
||||||
|
(article.image_url.startsWith('http') ?
|
||||||
|
article.image_url :
|
||||||
|
`https://launcher.hytale.com/launcher-feed/release/${article.image_url}`
|
||||||
|
) : ''
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch news:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getHytaleNews
|
||||||
|
};
|
||||||
34
backend/services/playerManager.js
Normal file
34
backend/services/playerManager.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { PLAYER_ID_FILE, APP_DIR } = require('../core/paths');
|
||||||
|
|
||||||
|
function getOrCreatePlayerId() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(APP_DIR)) {
|
||||||
|
fs.mkdirSync(APP_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(PLAYER_ID_FILE)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(PLAYER_ID_FILE, 'utf8'));
|
||||||
|
if (data.playerId) {
|
||||||
|
return data.playerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPlayerId = uuidv4();
|
||||||
|
fs.writeFileSync(PLAYER_ID_FILE, JSON.stringify({
|
||||||
|
playerId: newPlayerId,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
return newPlayerId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error managing player ID:', error);
|
||||||
|
return uuidv4();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOrCreatePlayerId
|
||||||
|
};
|
||||||
82
backend/services/versionManager.js
Normal file
82
backend/services/versionManager.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function getLatestClientVersion() {
|
||||||
|
try {
|
||||||
|
console.log('Fetching latest client version from API...');
|
||||||
|
const response = await axios.get('http://3.10.208.30:3002/api/version_client', {
|
||||||
|
timeout: 5000,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Hytale-F2P-Launcher'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.client_version) {
|
||||||
|
const version = response.data.client_version;
|
||||||
|
console.log(`Latest client version: ${version}`);
|
||||||
|
return version;
|
||||||
|
} else {
|
||||||
|
console.log('Warning: Invalid API response, falling back to default version');
|
||||||
|
return '4.pwr';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching client version:', error.message);
|
||||||
|
console.log('Warning: API unavailable, falling back to default version');
|
||||||
|
return '4.pwr';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getInstalledClientVersion() {
|
||||||
|
try {
|
||||||
|
console.log('Fetching installed client version from API...');
|
||||||
|
const response = await axios.get('http://3.10.208.30:3002/api/clientCheck', {
|
||||||
|
timeout: 5000,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Hytale-F2P-Launcher'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.client_version) {
|
||||||
|
const version = response.data.client_version;
|
||||||
|
console.log(`Installed client version: ${version}`);
|
||||||
|
return version;
|
||||||
|
} else {
|
||||||
|
console.log('Warning: Invalid clientCheck API response');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching installed client version:', error.message);
|
||||||
|
console.log('Warning: clientCheck API unavailable');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMultiClientVersion() {
|
||||||
|
try {
|
||||||
|
console.log('Fetching Multiplayer version from API...');
|
||||||
|
const response = await axios.get('http://3.10.208.30:3002/api/multi', {
|
||||||
|
timeout: 5000,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Hytale-F2P-Launcher'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.multi_version) {
|
||||||
|
const version = response.data.multi_version;
|
||||||
|
console.log(`Multiplayer version: ${version}`);
|
||||||
|
return version;
|
||||||
|
} else {
|
||||||
|
console.log('Warning: Invalid multi API response');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Multiplayer version:', error.message);
|
||||||
|
console.log('Multiplayer not available');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getLatestClientVersion,
|
||||||
|
getInstalledClientVersion,
|
||||||
|
getMultiClientVersion
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
|
||||||
const UPDATE_CHECK_URL = 'http://3.10.208.30:3002/api/version_launcher';
|
const UPDATE_CHECK_URL = 'http://3.10.208.30:3002/api/version_launcher';
|
||||||
const CURRENT_VERSION = '2.0.0';
|
const CURRENT_VERSION = '2.0.1';
|
||||||
const GITHUB_DOWNLOAD_URL = 'https://github.com/amiayweb/Hytale-F2P/';
|
const GITHUB_DOWNLOAD_URL = 'https://github.com/amiayweb/Hytale-F2P/';
|
||||||
|
|
||||||
class UpdateManager {
|
class UpdateManager {
|
||||||
|
|||||||
511
backend/utils/clientPatcher.js
Normal file
511
backend/utils/clientPatcher.js
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
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();
|
||||||
103
backend/utils/fileManager.js
Normal file
103
backend/utils/fileManager.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function downloadFile(url, dest, progressCallback) {
|
||||||
|
const response = await axios({
|
||||||
|
method: 'GET',
|
||||||
|
url: url,
|
||||||
|
responseType: 'stream',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': '*/*',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
'Referer': 'https://launcher.hytale.com/'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalSize = parseInt(response.headers['content-length'], 10);
|
||||||
|
let downloaded = 0;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const writer = fs.createWriteStream(dest);
|
||||||
|
|
||||||
|
response.data.on('data', (chunk) => {
|
||||||
|
downloaded += chunk.length;
|
||||||
|
if (progressCallback && totalSize > 0) {
|
||||||
|
const percent = Math.min(100, Math.max(0, (downloaded / totalSize) * 100));
|
||||||
|
const elapsed = (Date.now() - startTime) / 1000;
|
||||||
|
const speed = elapsed > 0 ? downloaded / elapsed : 0;
|
||||||
|
progressCallback(null, percent, speed, downloaded, totalSize);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
response.data.pipe(writer);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
writer.on('finish', resolve);
|
||||||
|
writer.on('error', reject);
|
||||||
|
response.data.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHomePageUIPath(gameLatest) {
|
||||||
|
function searchDirectory(dir) {
|
||||||
|
try {
|
||||||
|
const items = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.isFile() && item.name === 'HomePage.ui') {
|
||||||
|
return path.join(dir, item.name);
|
||||||
|
} else if (item.isDirectory()) {
|
||||||
|
const found = searchDirectory(path.join(dir, item.name));
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(gameLatest)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchDirectory(gameLatest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLogoPath(gameLatest) {
|
||||||
|
function searchDirectory(dir) {
|
||||||
|
try {
|
||||||
|
const items = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.isFile() && item.name === 'Logo@2x.png') {
|
||||||
|
return path.join(dir, item.name);
|
||||||
|
} else if (item.isDirectory()) {
|
||||||
|
const found = searchDirectory(path.join(dir, item.name));
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(gameLatest)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchDirectory(gameLatest);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
downloadFile,
|
||||||
|
findHomePageUIPath,
|
||||||
|
findLogoPath
|
||||||
|
};
|
||||||
73
backend/utils/platformUtils.js
Normal file
73
backend/utils/platformUtils.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
function getOS() {
|
||||||
|
if (process.platform === 'win32') return 'windows';
|
||||||
|
if (process.platform === 'darwin') return 'darwin';
|
||||||
|
if (process.platform === 'linux') return 'linux';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArch() {
|
||||||
|
return process.arch === 'x64' ? 'amd64' : process.arch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWaylandSession() {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionType = process.env.XDG_SESSION_TYPE;
|
||||||
|
if (sessionType && sessionType.toLowerCase() === 'wayland') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.WAYLAND_DISPLAY) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionId = process.env.XDG_SESSION_ID;
|
||||||
|
if (sessionId) {
|
||||||
|
const output = execSync(`loginctl show-session ${sessionId} -p Type`, { encoding: 'utf8' });
|
||||||
|
if (output && output.toLowerCase().includes('wayland')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupWaylandEnvironment() {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWaylandSession()) {
|
||||||
|
console.log('Detected X11 session, using default environment');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Detected Wayland session, configuring environment...');
|
||||||
|
|
||||||
|
const envVars = {
|
||||||
|
SDL_VIDEODRIVER: 'wayland',
|
||||||
|
GDK_BACKEND: 'wayland',
|
||||||
|
QT_QPA_PLATFORM: 'wayland',
|
||||||
|
MOZ_ENABLE_WAYLAND: '1',
|
||||||
|
_JAVA_AWT_WM_NONREPARENTING: '1'
|
||||||
|
};
|
||||||
|
|
||||||
|
envVars.ELECTRON_OZONE_PLATFORM_HINT = 'wayland';
|
||||||
|
|
||||||
|
console.log('Wayland environment variables:', envVars);
|
||||||
|
return envVars;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOS,
|
||||||
|
getArch,
|
||||||
|
isWaylandSession,
|
||||||
|
setupWaylandEnvironment
|
||||||
|
};
|
||||||
131
main.js
131
main.js
@@ -3,6 +3,9 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveChatUsername, loadChatUsername, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, isGameInstalled, uninstallGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched } = require('./backend/launcher');
|
const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveChatUsername, loadChatUsername, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, isGameInstalled, uninstallGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched } = require('./backend/launcher');
|
||||||
const UpdateManager = require('./backend/updateManager');
|
const UpdateManager = require('./backend/updateManager');
|
||||||
|
const logger = require('./backend/logger');
|
||||||
|
|
||||||
|
logger.interceptConsole();
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
let updateManager;
|
let updateManager;
|
||||||
@@ -66,7 +69,7 @@ function createWindow() {
|
|||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
devTools: false,
|
devTools: true,
|
||||||
webSecurity: true
|
webSecurity: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -116,9 +119,30 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
console.log('=== HYTALE F2P LAUNCHER STARTED ===');
|
||||||
|
console.log('Platform:', process.platform);
|
||||||
|
console.log('Architecture:', process.arch);
|
||||||
|
console.log('Electron version:', process.versions.electron);
|
||||||
|
console.log('Node.js version:', process.versions.node);
|
||||||
|
console.log('Log directory:', logger.getLogDirectory());
|
||||||
|
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
|
let timeoutReached = false;
|
||||||
|
|
||||||
|
const unlockPlayButton = () => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('lock-play-button', false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
timeoutReached = true;
|
||||||
|
console.warn('First launch check timeout reached, unlocking play button');
|
||||||
|
unlockPlayButton();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Starting first launch check...');
|
console.log('Starting first launch check...');
|
||||||
|
|
||||||
@@ -132,7 +156,19 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const firstLaunchResult = await handleFirstLaunchCheck(progressCallback);
|
const firstLaunchResult = await Promise.race([
|
||||||
|
handleFirstLaunchCheck(progressCallback),
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('First launch check timeout')), 12000);
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (timeoutReached) {
|
||||||
|
console.log('Timeout already reached, skipping result processing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('First launch check result:', firstLaunchResult);
|
console.log('First launch check result:', firstLaunchResult);
|
||||||
|
|
||||||
@@ -141,32 +177,39 @@ app.whenReady().then(async () => {
|
|||||||
console.log('Sending show-first-launch-update event...');
|
console.log('Sending show-first-launch-update event...');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('show-first-launch-update', {
|
mainWindow.webContents.send('show-first-launch-update', {
|
||||||
existingGame: firstLaunchResult.existingGame,
|
existingGame: firstLaunchResult.existingGame,
|
||||||
isFirstLaunch: firstLaunchResult.isFirstLaunch
|
isFirstLaunch: firstLaunchResult.isFirstLaunch
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
} else if (firstLaunchResult.isFirstLaunch && !firstLaunchResult.existingGame) {
|
} else if (firstLaunchResult.isFirstLaunch && !firstLaunchResult.existingGame) {
|
||||||
console.log('Sending show-first-launch-welcome event...');
|
console.log('Sending show-first-launch-welcome event...');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('show-first-launch-welcome');
|
mainWindow.webContents.send('show-first-launch-welcome');
|
||||||
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
mainWindow.webContents.send('lock-play-button', false);
|
unlockPlayButton();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
console.error('Error during first launch check:', error);
|
console.error('Error during first launch check:', error);
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (!timeoutReached) {
|
||||||
mainWindow.webContents.send('lock-play-button', false);
|
unlockPlayButton();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
|
console.log('=== LAUNCHER CLOSING ===');
|
||||||
|
|
||||||
// Clean up Discord RPC connection
|
// Clean up Discord RPC connection
|
||||||
if (discordRPC) {
|
if (discordRPC) {
|
||||||
try {
|
try {
|
||||||
@@ -198,6 +241,12 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath) =
|
|||||||
|
|
||||||
const result = await launchGameWithVersionCheck(playerName, progressCallback, javaPath, installPath);
|
const result = await launchGameWithVersionCheck(playerName, progressCallback, javaPath, installPath);
|
||||||
|
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
setTimeout(() => {
|
||||||
|
mainWindow.webContents.send('progress-complete');
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Launch error:', error);
|
console.error('Launch error:', error);
|
||||||
@@ -223,6 +272,12 @@ ipcMain.handle('install-game', async (event, playerName, javaPath, installPath)
|
|||||||
|
|
||||||
const result = await installGame(playerName, progressCallback, javaPath, installPath);
|
const result = await installGame(playerName, progressCallback, javaPath, installPath);
|
||||||
|
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
setTimeout(() => {
|
||||||
|
mainWindow.webContents.send('progress-complete');
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Install error:', error);
|
console.error('Install error:', error);
|
||||||
@@ -257,6 +312,7 @@ ipcMain.handle('load-java-path', () => {
|
|||||||
|
|
||||||
ipcMain.handle('save-install-path', (event, installPath) => {
|
ipcMain.handle('save-install-path', (event, installPath) => {
|
||||||
saveInstallPath(installPath);
|
saveInstallPath(installPath);
|
||||||
|
logger.updateInstallPath();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -311,8 +367,16 @@ ipcMain.handle('mark-as-launched', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('is-game-installed', () => {
|
ipcMain.handle('is-game-installed', async () => {
|
||||||
return isGameInstalled();
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
Promise.resolve(isGameInstalled()),
|
||||||
|
new Promise((resolve) => setTimeout(() => resolve(false), 5000))
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking game installation:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('uninstall-game', async () => {
|
ipcMain.handle('uninstall-game', async () => {
|
||||||
@@ -345,6 +409,23 @@ ipcMain.handle('open-external', async (event, url) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('open-game-location', async () => {
|
||||||
|
try {
|
||||||
|
const { getResolvedAppDir } = require('./backend/launcher');
|
||||||
|
const gameDir = path.join(getResolvedAppDir(), 'release', 'package', 'game');
|
||||||
|
|
||||||
|
if (fs.existsSync(gameDir)) {
|
||||||
|
await shell.openPath(gameDir);
|
||||||
|
return { success: true };
|
||||||
|
} else {
|
||||||
|
throw new Error('Game directory not found');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open game location:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('browse-java-path', async () => {
|
ipcMain.handle('browse-java-path', async () => {
|
||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
const isMac = process.platform === 'darwin';
|
const isMac = process.platform === 'darwin';
|
||||||
@@ -392,7 +473,10 @@ ipcMain.handle('save-settings', async (event, settings) => {
|
|||||||
try {
|
try {
|
||||||
if (settings.playerName) saveUsername(settings.playerName);
|
if (settings.playerName) saveUsername(settings.playerName);
|
||||||
if (settings.javaPath !== undefined) saveJavaPath(settings.javaPath);
|
if (settings.javaPath !== undefined) saveJavaPath(settings.javaPath);
|
||||||
if (settings.installPath !== undefined) saveInstallPath(settings.installPath);
|
if (settings.installPath !== undefined) {
|
||||||
|
saveInstallPath(settings.installPath);
|
||||||
|
logger.updateInstallPath();
|
||||||
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save settings error:', error);
|
console.error('Save settings error:', error);
|
||||||
@@ -564,3 +648,34 @@ ipcMain.handle('window-minimize', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-log-directory', () => {
|
||||||
|
return logger.getLogDirectory();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-recent-logs', async (event, maxLines = 100) => {
|
||||||
|
try {
|
||||||
|
const logDir = logger.getLogDirectory();
|
||||||
|
if (!logDir) return null;
|
||||||
|
|
||||||
|
// Find the most recent log file
|
||||||
|
const files = fs.readdirSync(logDir)
|
||||||
|
.filter(file => file.startsWith('launcher-') && file.endsWith('.log'))
|
||||||
|
.map(file => ({
|
||||||
|
name: file,
|
||||||
|
path: path.join(logDir, file),
|
||||||
|
mtime: fs.statSync(path.join(logDir, file)).mtime
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
|
||||||
|
if (files.length === 0) return null;
|
||||||
|
|
||||||
|
const latestLogFile = files[0].path;
|
||||||
|
const content = fs.readFileSync(latestLogFile, 'utf8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
return lines.slice(-maxLines).join('\n');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading logs:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
4956
package-lock.json
generated
4956
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hytale-f2p-launcher",
|
"name": "hytale-f2p-launcher",
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
|
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
|
||||||
"homepage": "https://github.com/amiayweb/Hytale-F2P",
|
"homepage": "https://github.com/amiayweb/Hytale-F2P",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"adm-zip": "^0.5.10",
|
"adm-zip": "^0.5.10",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
"tar": "6.2.1",
|
"tar": "^6.2.1",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
@@ -97,7 +97,8 @@
|
|||||||
{
|
{
|
||||||
"target": "dmg",
|
"target": "dmg",
|
||||||
"arch": [
|
"arch": [
|
||||||
"universal"
|
"x64",
|
||||||
|
"arm64"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -119,3 +120,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getHytaleNews: () => ipcRenderer.invoke('get-hytale-news'),
|
getHytaleNews: () => ipcRenderer.invoke('get-hytale-news'),
|
||||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||||
openExternalLink: (url) => ipcRenderer.invoke('openExternalLink', url),
|
openExternalLink: (url) => ipcRenderer.invoke('openExternalLink', url),
|
||||||
|
openGameLocation: () => ipcRenderer.invoke('open-game-location'),
|
||||||
saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings),
|
saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings),
|
||||||
loadSettings: () => ipcRenderer.invoke('load-settings'),
|
loadSettings: () => ipcRenderer.invoke('load-settings'),
|
||||||
getLocalAppData: () => ipcRenderer.invoke('get-local-app-data'),
|
getLocalAppData: () => ipcRenderer.invoke('get-local-app-data'),
|
||||||
@@ -33,6 +34,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
onProgressUpdate: (callback) => {
|
onProgressUpdate: (callback) => {
|
||||||
ipcRenderer.on('progress-update', (event, data) => callback(data));
|
ipcRenderer.on('progress-update', (event, data) => callback(data));
|
||||||
},
|
},
|
||||||
|
onProgressComplete: (callback) => {
|
||||||
|
ipcRenderer.on('progress-complete', () => callback());
|
||||||
|
},
|
||||||
getUserId: () => ipcRenderer.invoke('get-user-id'),
|
getUserId: () => ipcRenderer.invoke('get-user-id'),
|
||||||
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
|
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
|
||||||
openDownloadPage: () => ipcRenderer.invoke('open-download-page'),
|
openDownloadPage: () => ipcRenderer.invoke('open-download-page'),
|
||||||
@@ -54,5 +58,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
},
|
},
|
||||||
onLockPlayButton: (callback) => {
|
onLockPlayButton: (callback) => {
|
||||||
ipcRenderer.on('lock-play-button', (event, locked) => callback(locked));
|
ipcRenderer.on('lock-play-button', (event, locked) => callback(locked));
|
||||||
}
|
},
|
||||||
|
|
||||||
|
getLogDirectory: () => ipcRenderer.invoke('get-log-directory'),
|
||||||
|
getRecentLogs: (maxLines) => ipcRenderer.invoke('get-recent-logs', maxLines)
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user