Compare commits

...

89 Commits

Author SHA1 Message Date
sanasol
b6041c1908 Add client and server patching functionality to support custom authentication domain and token system 2026-01-19 16:03:12 +01:00
sanasol
7b6c07283a Merge branch 'refs/heads/main' into test-mac 2026-01-19 15:11:19 +01:00
AMIAY
300616ba82 Update SERVER.md 2026-01-19 01:55:33 +01:00
sanasol
115e76e461 Update macOS build target architectures to include x64 and arm64 2026-01-19 01:44:11 +01:00
AMIAY
9fdd6f1f44 Update BUILD.md 2026-01-19 00:26:32 +01:00
AMIAY
f0b2342c71 Merge pull request #47 from GreenKod/refactor-split-file
refactor: split main file into smaller modules for better maintainabi…
2026-01-18 21:06:09 +01:00
greenkod
7dbc900338 refactor: split main file into smaller modules for better maintainability 2026-01-18 21:29:37 +03:00
AMIAY
bc31f58c9c Update print statement from 'Hello' to 'Goodbye' 2026-01-18 16:07:31 +01:00
AMIAY
7e5a1577a3 Update and rename styles.css to style.css 2026-01-18 16:07:19 +01:00
AMIAY
84f0c0ba71 Delete GUI/style.css 2026-01-18 16:07:10 +01:00
AMIAY
7ede6c2f27 Add files via upload 2026-01-18 15:42:24 +01:00
AMIAY
be1a24a992 Add files via upload 2026-01-18 15:42:23 +01:00
AMIAY
9a751958b0 Add files via upload 2026-01-18 15:42:22 +01:00
AMIAY
9fcf603e08 Add files via upload 2026-01-18 15:42:20 +01:00
AMIAY
4bc1661587 Update package.json with new configuration 2026-01-18 14:41:51 +01:00
AMIAY
512a53aee7 Enhance README with additional resources and links
Updated instructions to include links to Releases and SERVER.md.
2026-01-18 14:41:22 +01:00
AMIAY
cabb5a57d2 Add Hytale F2P Server Setup Guide
Added a comprehensive setup guide for the Hytale F2P server, including server file setup, network configuration with Radmin VPN, RAM allocation recommendations, and tips for optimal performance.
2026-01-18 14:40:21 +01:00
AMIAY
c0dc65c59a Update dependencies and fix tar version to avoid workflow & electron-builder problem 2026-01-18 14:07:50 +01:00
AMIAY
b748e7316d Update package-lock.json 2026-01-18 13:59:41 +01:00
AMIAY
1c7f24c67c Update package-lock.json 2026-01-18 13:58:23 +01:00
AMIAY
b0c8c6affa Add npm install step to release workflow
Added npm install step before npm ci for all platforms.
2026-01-18 13:55:39 +01:00
AMIAY
472e55668a Update 'tar' dependency version
Updated 'tar' dependency version from 7.0.0 to 7.5.3.
2026-01-18 13:52:50 +01:00
AMIAY
6d09bba996 Merge pull request #34 from fazrigading/fix/tar-7.5.3
fix: update tar to 7.5.3 and add override for GHSA-8qq5-rm4j-mr97
2026-01-18 13:51:11 +01:00
AMIAY
6f3ae4aed7 Add README1.md to the .github directory 2026-01-18 13:45:48 +01:00
AMIAY
ee40bab9c3 Merge pull request #33 from sanasol/feature/build-releases
Github action to build binaries
2026-01-18 13:44:38 +01:00
AMIAY
84dc63b13e Change VPN software reference in server setup
Updated instructions to use Radmin VPN instead of Hamachi.
2026-01-18 13:44:00 +01:00
Fazri Gading
022a1bfde1 Update .gitignore 2026-01-18 20:36:03 +08:00
sanasol
2896ca862b Add README for GitHub Actions build and release workflow detailing triggers, platforms, and artifact generation 2026-01-18 13:27:36 +01:00
Fazri Gading
651cc16485 add override to ensure
nested dependencies in electron-builder are also patched against
arbitrary file overwrite exploits
2026-01-18 20:26:42 +08:00
Fazri Gading
5d986768d9 Delete package-lock.json 2026-01-18 20:18:09 +08:00
Fazri Gading
0f0e360cad Merge branch 'fix/tar-7.5.3' of https://github.com/fazrigading/Hytale-F2P into fix/tar-7.5.3 2026-01-18 20:16:10 +08:00
Fazri Gading
9c95bbb174 removed override for tar>=7.5.3 dep 2026-01-18 20:11:59 +08:00
Fazri Gading
23e32b3688 ignore package lock to avoid conflicts 2026-01-18 20:11:09 +08:00
Fazri Gading
f138ada0a6 Merge branch 'amiayweb:main' into fix/tar-7.5.3 2026-01-18 20:09:57 +08:00
sanasol
b5adf4aa6c Add GitHub Actions workflow for building and releasing artifacts across Linux, Windows, and macOS platforms 2026-01-18 13:08:51 +01:00
sanasol
6df5b056c5 Add GitHub Actions workflow for building and releasing artifacts across Linux, Windows, and macOS platforms 2026-01-18 12:43:19 +01:00
sanasol
fe45c5810c Add GitHub Actions workflow for building and releasing artifacts across Linux, Windows, and macOS platforms 2026-01-18 12:39:22 +01:00
sanasol
df46a74089 Add GitHub Actions workflow for building and releasing artifacts across Linux, Windows, and macOS platforms 2026-01-18 12:36:11 +01:00
AMIAY
e14c80f3dd Merge pull request #31 from Terromur/main
Add PKGBUILD
thanks for your contributions
2026-01-18 12:08:12 +01:00
Terromur
7f61d9d5b8 Update PKGBUILD 2026-01-18 16:07:00 +05:00
Fazri Gading
224f3f77fb chore: pin tar >=7.5.3 and add overrides to address vuln 2026-01-18 19:02:57 +08:00
Terromur
abcbd6823e Add PKGBUILD 2026-01-18 15:58:34 +05:00
AMIAY
2dcd0064e8 Merge pull request #29 from letha11/main
fix: typo css file link for the gui
2026-01-18 11:36:10 +01:00
AMIAY
ad8b6a7d3b Merge pull request #30 from Terromur/main
Fix build deb [Ubuntu]
2026-01-18 11:34:35 +01:00
Terromur
ebcfdc4e3b Fix build deb 2026-01-18 13:15:18 +05:00
letha11
a275d15d38 fix: typo css file link for the gui 2026-01-18 14:29:50 +07:00
AMIAY
778d48fb62 Refactor main.js for improved structure and updates 2026-01-18 04:21:47 +01:00
AMIAY
f251f77c07 Merge pull request #25 from Terromur/main
Removing unnecessary variables in launcher.js and deleting package-lock.json
2026-01-18 04:12:14 +01:00
Terromur
ec03f9e6bc Delete package-lock.json
Fixed problems with npm install
2026-01-18 08:01:59 +05:00
Terromur
517dedc5c7 Update launcher.js
Removing unnecessary variables
2026-01-18 07:59:24 +05:00
AMIAY
d6bfda964b Add Discord server badge to README 2026-01-18 03:33:12 +01:00
AMIAY
f06c97b70b Update instructions for using Hamachi
Added a note to check YouTube for Hamachi usage.
2026-01-18 02:32:34 +01:00
AMIAY
ed84f46386 Add contact section with Discord badge
Added contact section with Discord link to README.
2026-01-18 02:14:32 +01:00
AMIAY
07c78f2fb0 Update version badge from 1.0.2 to 2.0.0 2026-01-18 02:11:01 +01:00
AMIAY
b64f8fa0a3 Add screenshots section to README
Added a new section for screenshots in the README.
2026-01-18 02:03:35 +01:00
AMIAY
395b317e80 Delete index.html 2026-01-18 01:59:10 +01:00
AMIAY
68bd0a1902 Update README.md 2026-01-18 01:58:33 +01:00
AMIAY
45f8f17751 Update README with third-party software requirement
Added a note about needing third-party software for server setup.
2026-01-18 01:56:05 +01:00
AMIAY
1e93105cac Add files via upload 2026-01-18 01:52:38 +01:00
AMIAY
3ebe727f58 Add files via upload 2026-01-18 01:52:24 +01:00
AMIAY
01757fcdfa Add imports for multiple modules in script.js 2026-01-18 01:52:14 +01:00
AMIAY
1c9becc6b9 Add files via upload 2026-01-18 01:51:46 +01:00
AMIAY
a81bb943b6 Create style.css 2026-01-18 01:51:37 +01:00
AMIAY
b7e44ca418 Add files via upload 2026-01-18 01:50:47 +01:00
AMIAY
b6bb1c722a Add files via upload 2026-01-18 01:50:24 +01:00
AMIAY
09b2d05e31 Merge pull request #23 from colbster937/main
resize icon.png from 256x256 to 512x512
2026-01-17 22:17:10 +01:00
Colbster937
08e3fa4222 resize icon.png from 256x256 to 512x512 2026-01-17 15:06:10 -06:00
AMIAY
71f635c537 Merge pull request #22 from Citeli-py/main
Fix: Error on build for linux [Ubuntu 22] #20
2026-01-17 20:54:18 +01:00
Citeli-py
567a1fadde build: update packages 2026-01-17 16:32:15 -03:00
Citeli-py
10cdb14610 fix: email for .deb build 2026-01-17 16:32:05 -03:00
Citeli-py
049937718b add: icon in png for linux 2026-01-17 16:26:01 -03:00
AMIAY
528d10e902 Add contributor link for @sanasol 2026-01-17 16:16:42 +01:00
AMIAY
eb9aa5084f Merge pull request #11 from sanasol/hotfix/macos-code-sign-sentry-disable
Fix: macOS build crash on game launch and server not booting
2026-01-17 15:07:28 +01:00
AMIAY
569a06b13d Add disclaimer about project affiliation and usage
Added a disclaimer regarding the project's purpose and affiliation.
2026-01-17 15:06:38 +01:00
sanasol
335db04cee MacOs build crash on game launch and server not botting 2026-01-17 14:32:43 +01:00
AMIAY
f49c5c02dd Update README.md 2026-01-16 19:44:55 +01:00
AMIAY
0bf83df285 Refactor launcher.js for improved app directory management
Refactor launcher.js to improve directory handling and function parameters.
2026-01-16 16:19:46 +01:00
AMIAY
c597a08a38 Refactor index.html for improved launcher functionality
Updated HTML structure and JavaScript functionality for the Hytale Launcher, including enhancements to the installation process and UI elements.
2026-01-16 16:19:20 +01:00
AMIAY
7870b9e3d8 Update package-lock.json 2026-01-16 16:18:57 +01:00
AMIAY
3a7e2e762e Update package.json 2026-01-16 16:18:26 +01:00
AMIAY
2fae72e3f9 Add install path handling to preload.js 2026-01-16 16:18:16 +01:00
AMIAY
4ca8091bb9 Update main.js 2026-01-16 16:18:03 +01:00
AMIAY
df675f270a Merge pull request #5 from crimera/main
Only generate uuid for new username strings
2026-01-16 14:56:42 +01:00
crimera
112801f220 Only generate uuid for new username strings 2026-01-16 21:08:43 +08:00
AMIAY
d80b905879 Merge pull request #2 from chasem-dev/chasem.dev/fix-mac
Better support for MAC. Add Java path configuration and input handling.
2026-01-14 17:53:28 +01:00
chasem-dev
6873e2e4bf Add Java path configuration and input handling
- Introduced a new input field for Java path in the launcher UI.
- Updated the main process to handle saving and loading of the Java path.
- Enhanced game launch functionality to accept a Java path parameter.
- Added Java detection logic to find the Java executable on the system.
- Created a .gitignore file to exclude build artifacts and dependencies.
2026-01-14 10:11:01 -05:00
AMIAY
b90eeb344b Update README.md 2026-01-14 00:47:29 +01:00
AMIAY
00287302ff Add files via upload 2026-01-14 00:42:29 +01:00
AMIAY
692de4eb26 Add launcher.js for game management and launching 2026-01-14 00:42:12 +01:00
46 changed files with 17228 additions and 41 deletions

61
.github/README1.md vendored Normal file
View File

@@ -0,0 +1,61 @@
# GitHub Actions
## Build and Release Workflow
The `release.yml` workflow automatically builds the launcher for all platforms.
### Triggers
| Trigger | Builds | Creates Release |
|---------|--------|-----------------|
| Push to `main` | Yes | No |
| Push tag `v*` | Yes | Yes |
| Manual dispatch | Yes | No |
### Platforms
All builds run in parallel:
- **Linux** (ubuntu-latest): AppImage, deb
- **Windows** (windows-latest): NSIS installer, portable exe
- **macOS** (macos-latest): Universal DMG (Intel + Apple Silicon)
### Creating a Release
1. Update version in `package.json`
2. Commit and push to `main`
3. Create and push a version tag:
```bash
git tag v2.0.1
git push origin v2.0.1
```
The workflow will:
1. Build all platforms in parallel
2. Upload artifacts to GitHub Release
3. Generate release notes automatically
### Build Artifacts
After each build, artifacts are available in the Actions tab for 90 days:
- `linux-builds`: `.AppImage`, `.deb`
- `windows-builds`: `.exe`
- `macos-builds`: `.dmg`, `.zip`, `latest-mac.yml`
### Local Development
Build locally for your platform:
```bash
npm run build:linux
npm run build:win
npm run build:mac
```
Or build all platforms (requires appropriate OS):
```bash
npm run build:all
```

92
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,92 @@
name: Build and Release
on:
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
jobs:
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm install
- run: npm ci
- run: npx electron-builder --linux --publish never
- uses: actions/upload-artifact@v4
with:
name: linux-builds
path: |
dist/*.AppImage
dist/*.deb
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm install
- run: npm ci
- run: npx electron-builder --win --publish never
- uses: actions/upload-artifact@v4
with:
name: windows-builds
path: |
dist/*.exe
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm install
- run: npm ci
- run: npx electron-builder --mac --publish never
- uses: actions/upload-artifact@v4
with:
name: macos-builds
path: |
dist/*.dmg
dist/*.zip
dist/latest-mac.yml
release:
needs: [build-linux, build-windows, build-macos]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Display structure of downloaded files
run: ls -R artifacts
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
artifacts/linux-builds/*
artifacts/windows-builds/*
artifacts/macos-builds/*
generate_release_notes: true
draft: false
prerelease: false

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
dist/*
node_modules/*
package-lock.json

View File

@@ -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

BIN
GUI/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

448
GUI/index.html Normal file
View File

@@ -0,0 +1,448 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hytale F2P Launcher</title>
<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 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">
</head>
<body class="bg-black text-white overflow-hidden font-sans select-none" tabindex="-1">
<div class="absolute inset-0 z-0">
<img src="https://i.imgur.com/Visrk66.png" alt="Background" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-black/60"></div>
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg"%3E%3Cfilter id="noiseFilter"%3E%3CfeTurbulence type="fractalNoise" baseFrequency="0.65" numOctaves="3" stitchTiles="stitch"/%3E%3C/filter%3E%3Crect width="100%25" height="100%25" filter="url(%23noiseFilter)" opacity="0.1"/%3E%3C/svg%3E')] opacity-20"></div>
</div>
<div class="flex w-full h-screen relative z-10">
<nav class="sidebar">
<div class="sidebar-logo">
<img src="./icon.png" alt="Hytale Logo" />
</div>
<div class="sidebar-nav">
<div class="nav-item active" data-page="play">
<i class="fas fa-play"></i>
<span class="nav-tooltip">Play</span>
</div>
<div class="nav-item" data-page="mods">
<i class="fas fa-box"></i>
<span class="nav-tooltip">Mods</span>
</div>
<div class="nav-item" data-page="news">
<i class="fas fa-newspaper"></i>
<span class="nav-tooltip">News</span>
</div>
<div class="nav-item" data-page="chat">
<i class="fas fa-comments"></i>
<span class="nav-tooltip">Players Chat</span>
</div>
<div class="nav-item" data-page="settings">
<i class="fas fa-cog"></i>
<span class="nav-tooltip">Settings</span>
</div>
<div class="nav-item" data-page="skins">
<i class="fas fa-user"></i>
<span class="nav-tooltip">Skins</span>
</div>
</div>
</nav>
<main class="main-content">
<header class="header">
<div id="playersOnlineCounter" class="players-counter">
<i class="fas fa-users"></i>
<span class="counter-label">Players:</span>
<span id="onlineCount" class="counter-value">0</span>
</div>
<div class="window-controls">
<button class="control-btn minimize" onclick="window.electronAPI?.minimizeWindow()">
<i class="fas fa-minus"></i>
</button>
<button class="control-btn close" onclick="window.electronAPI?.closeWindow()">
<i class="fas fa-times"></i>
</button>
</div>
</header>
<div class="game-title-section">
<h1 class="game-title">
HY<span class="title-accent">TALE</span>
</h1>
<div class="game-tags">
<span class="tag">FREE TO PLAY</span>
</div>
</div>
<div class="content-pages">
<div id="install-page" class="page install-page">
<div class="install-content">
<div class="install-header">
<h1 class="install-title">
HYTA<span class="title-accent">LE</span>
</h1>
<p class="install-subtitle">FREE TO PLAY LAUNCHER</p>
</div>
<div class="install-form">
<div class="form-group">
<label class="form-label">Player Name</label>
<input type="text" id="installPlayerName" placeholder="Enter your name" class="form-input" value="Player" />
</div>
<div class="form-group">
<label class="checkbox-group">
<input type="checkbox" id="installCustomCheck" class="custom-checkbox">
<span class="checkbox-label">Custom Installation</span>
</label>
<div id="installCustomOptions" class="custom-options">
<div class="form-subgroup">
<label class="form-label">Installation Folder</label>
<div class="input-with-button">
<input type="text" id="installPath" placeholder="Default location" class="form-input" readonly />
<button onclick="browseInstallPath()" class="browse-btn">
<i class="fas fa-folder-open"></i>
</button>
</div>
</div>
</div>
</div>
<button id="installBtn" class="install-button" onclick="installGame()">
<i class="fas fa-download mr-2"></i>
<span id="installText">INSTALL HYTALE</span>
</button>
</div>
</div>
</div>
<div id="launcher-container" class="launcher-container" style="display: none;">
<div id="play-page" class="page active">
<div class="play-section">
<div class="play-content">
<div class="play-header">
<h2 class="play-title">
<i class="fas fa-play-circle mr-2"></i>
READY TO PLAY
</h2>
<p class="play-subtitle">Launch Hytale and enter the adventure</p>
</div>
<button id="homePlayBtn" class="home-play-button" onclick="launch()">
<i class="fas fa-play"></i>
<span>PLAY HYTALE</span>
</button>
</div>
</div>
<div class="news-section">
<div class="news-header">
<h2 class="news-title">
<i class="fas fa-star mr-2"></i>
LATEST NEWS
</h2>
<button class="view-all-btn" onclick="navigateToPage('news')">
VIEW ALL <i class="fas fa-arrow-right ml-1"></i>
</button>
</div>
<div id="newsGrid" class="news-grid-horizontal"></div>
</div>
</div>
<div id="mods-page" class="page">
<div class="mods-header">
<div class="mods-search-container">
<i class="fas fa-search"></i>
<input type="text" id="modsSearch" placeholder="Search mods..." class="mods-search" />
</div>
<div class="mods-actions">
<button id="myModsBtn" class="mods-btn-primary">
<i class="fas fa-box"></i>
MY MODS
</button>
</div>
</div>
<div id="browseModsList" class="mods-browse-container">
</div>
<div class="mods-pagination">
<button id="prevPage" class="pagination-btn">
<i class="fas fa-chevron-left"></i>
PREVIOUS
</button>
<span class="pagination-info">
Page <span id="currentPage">1</span> of <span id="totalPages">1</span>
</span>
<button id="nextPage" class="pagination-btn">
NEXT
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
<div id="news-page" class="page">
<div class="news-header">
<h2 class="news-title">
<i class="fas fa-newspaper mr-2"></i>
ALL NEWS
</h2>
</div>
<div id="allNewsGrid" class="news-grid-full"></div>
</div>
<div id="chat-page" class="page">
<div class="chat-container">
<div class="chat-header">
<h2 class="chat-title">
<i class="fas fa-comments mr-2"></i>
PLAYERS CHAT
</h2>
<div class="chat-online-badge">
<i class="fas fa-circle"></i>
<span id="chatOnlineCount">0</span> online
</div>
</div>
<div class="chat-body">
<div id="chatMessages" class="chat-messages">
</div>
</div>
<div class="chat-footer">
<div class="chat-input-container">
<textarea
id="chatInput"
class="chat-input"
placeholder="Type your message... (Links are automatically censored)"
rows="1"
maxlength="500"
></textarea>
<button id="chatSendBtn" class="chat-send-btn">
<i class="fas fa-paper-plane"></i>
</button>
</div>
<div class="chat-footer-info">
<span class="chat-char-counter" id="chatCharCounter">0/500</span>
<span class="chat-warning-text">
<i class="fas fa-shield-alt"></i>
Secure chat - Links are censored
</span>
</div>
</div>
</div>
</div>
<div id="settings-page" class="page">
<div class="settings-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="fas fa-cog mr-2"></i>
SETTINGS
</h2>
</div>
<div class="settings-content">
<div class="settings-section">
<h3 class="settings-section-title">
<i class="fas fa-coffee"></i>
Java Runtime
</h3>
<div class="settings-option">
<label class="settings-checkbox">
<input type="checkbox" id="customJavaCheck" />
<span class="checkmark"></span>
<div class="checkbox-content">
<div class="checkbox-title">Use Custom Java Path</div>
<div class="checkbox-description">Override the bundled Java runtime with your own installation</div>
</div>
</label>
</div>
<div id="customJavaOptions" class="custom-java-options" style="display: none;">
<div class="settings-input-group">
<label class="settings-input-label">Java Executable Path</label>
<div class="settings-input-with-button">
<input
type="text"
id="customJavaPath"
class="settings-input"
placeholder="Select Java path..."
readonly
/>
<button id="browseJavaBtn" class="settings-browse-btn">
<i class="fas fa-folder-open"></i>
Browse
</button>
</div>
<p class="settings-hint">
<i class="fas fa-info-circle"></i>
Select the Java installation folder (supports Windows, Mac, Linux)
</p>
</div>
</div>
</div>
<div class="settings-section">
<h3 class="settings-section-title">
<i class="fas fa-gamepad"></i>
Game Options
</h3>
<div class="settings-option">
<div class="settings-input-group">
<label class="settings-input-label">Player Name</label>
<input
type="text"
id="settingsPlayerName"
class="settings-input"
placeholder="Enter your player name"
maxlength="16"
/>
<p class="settings-hint">
<i class="fas fa-user"></i>
This name will be used in-game (1-16 characters)
</p>
</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 id="skins-page" class="page">
<div class="placeholder-content">
<i class="fas fa-user text-6xl mb-4 text-purple-500"></i>
<h2>Skins</h2>
<p>Skin customization coming soon...</p>
</div>
</div>
</div>
</div>
</main>
</div>
<div id="myModsModal" class="mods-modal">
<div class="mods-modal-content">
<div class="mods-modal-header">
<h2 class="mods-modal-title">
<i class="fas fa-box mr-2"></i>
MY MODS
</h2>
<button id="closeMyModsModal" class="mods-modal-close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="mods-modal-body">
<div id="installedModsList" class="installed-mods-list">
</div>
</div>
</div>
</div>
<div id="progressOverlay" class="progress-overlay" style="display: none;">
<div class="progress-content">
<div class="progress-info">
<span id="progressText">Initializing...</span>
<span id="progressPercent">0%</span>
</div>
<div class="progress-bar-container">
<div id="progressBarFill" class="progress-bar-fill"></div>
</div>
<div class="progress-details">
<span id="progressSpeed"></span>
<span id="progressSize"></span>
</div>
</div>
</div>
<div id="chatUsernameModal" class="chat-username-modal" style="display: none;">
<div class="chat-username-modal-content">
<div class="chat-username-modal-header">
<h2 class="chat-username-modal-title">
<i class="fas fa-comments mr-2"></i>
Join Chat
</h2>
</div>
<div class="chat-username-modal-body">
<p class="chat-username-modal-description">
Choose a username to join the Players Chat
</p>
<div class="chat-username-input-group">
<label for="chatUsernameInput" class="chat-username-label">Username</label>
<input
type="text"
id="chatUsernameInput"
class="chat-username-input"
placeholder="Enter your username..."
maxlength="20"
autocomplete="off"
/>
<span class="chat-username-hint">3-20 characters, letters, numbers, - and _ only</span>
<span id="chatUsernameError" class="chat-username-error"></span>
</div>
</div>
<div class="chat-username-modal-footer">
<button id="chatUsernameCancel" class="chat-username-btn-cancel">
<i class="fas fa-times"></i>
Cancel
</button>
<button id="chatUsernameSubmit" class="chat-username-btn-submit">
<i class="fas fa-check"></i>
Join Chat
</button>
</div>
</div>
</div>
<footer class="fixed bottom-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-sm px-4 py-2">
<div class="flex items-center justify-center text-xs text-gray-400">
<span>Made by <a href="https://github.com/amiayweb" target="_blank" class="text-blue-400 hover:text-blue-300 transition-colors">@amiayweb</a></span>
<span class="mx-2">|</span>
<span>Contributors:
<a href="https://github.com/chasem-dev" target="_blank" class="text-blue-400 hover:text-blue-300 transition-colors">@chasem-dev</a>,
<a href="https://github.com/crimera" target="_blank" class="text-blue-400 hover:text-blue-300 transition-colors">@crimera</a>,
<a href="https://github.com/sanasol" target="_blank" class="text-blue-400 hover:text-blue-300 transition-colors">@sanasol</a>,
<a href="https://github.com/Terromur" target="_blank" class="text-blue-400 hover:text-blue-300 transition-colors">@terromur</a>,
<a href="https://github.com/ericiskoolbeans" target="_blank" class="text-blue-400 hover:text-blue-300 transition-colors">@ericiskoolbeans</a>
</span>
</div>
</footer>
<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>
</body>
</html>

358
GUI/js/chat.js Normal file
View File

@@ -0,0 +1,358 @@
let socket = null;
let isAuthenticated = false;
let messageQueue = [];
let chatUsername = '';
const SOCKET_URL = 'http://3.10.208.30:3001';
const MAX_MESSAGE_LENGTH = 500;
export async function initChat() {
if (window.electronAPI?.loadChatUsername) {
chatUsername = await window.electronAPI.loadChatUsername();
}
if (!chatUsername || chatUsername.trim() === '') {
showUsernameModal();
return;
}
setupChatUI();
await connectToChat();
}
function showUsernameModal() {
const modal = document.getElementById('chatUsernameModal');
if (modal) {
modal.style.display = 'flex';
const input = document.getElementById('chatUsernameInput');
if (input) {
setTimeout(() => input.focus(), 100);
}
}
}
function hideUsernameModal() {
const modal = document.getElementById('chatUsernameModal');
if (modal) {
modal.style.display = 'none';
}
}
async function submitChatUsername() {
const input = document.getElementById('chatUsernameInput');
const errorMsg = document.getElementById('chatUsernameError');
if (!input) return;
const username = input.value.trim();
if (username.length === 0) {
if (errorMsg) errorMsg.textContent = 'Username cannot be empty';
return;
}
if (username.length < 3) {
if (errorMsg) errorMsg.textContent = 'Username must be at least 3 characters';
return;
}
if (username.length > 20) {
if (errorMsg) errorMsg.textContent = 'Username must be 20 characters or less';
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
if (errorMsg) errorMsg.textContent = 'Username can only contain letters, numbers, - and _';
return;
}
chatUsername = username;
if (window.electronAPI?.saveChatUsername) {
await window.electronAPI.saveChatUsername(username);
}
hideUsernameModal();
setupChatUI();
await connectToChat();
}
function setupChatUI() {
const sendBtn = document.getElementById('chatSendBtn');
const chatInput = document.getElementById('chatInput');
const chatMessages = document.getElementById('chatMessages');
if (!sendBtn || !chatInput || !chatMessages) {
console.warn('Chat UI elements not found');
return;
}
sendBtn.addEventListener('click', () => {
sendMessage();
});
chatInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
chatInput.addEventListener('input', () => {
if (chatInput.value.length > MAX_MESSAGE_LENGTH) {
chatInput.value = chatInput.value.substring(0, MAX_MESSAGE_LENGTH);
}
updateCharCounter();
});
updateCharCounter();
}
async function connectToChat() {
try {
if (!window.io) {
await loadSocketIO();
}
const userId = await window.electronAPI?.getUserId();
if (!userId) {
console.error('User ID not available');
addSystemMessage('Error: Could not connect to chat');
return;
}
if (!chatUsername || chatUsername.trim() === '') {
console.error('Chat username not set');
addSystemMessage('Error: Username not set');
return;
}
socket = io(SOCKET_URL, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000
});
socket.on('connect', () => {
console.log('Connected to chat server');
socket.emit('authenticate', { username: chatUsername, userId });
});
socket.on('authenticated', (data) => {
isAuthenticated = true;
addSystemMessage(`Connected as ${data.username}`);
while (messageQueue.length > 0) {
const msg = messageQueue.shift();
socket.emit('send_message', { message: msg });
}
});
socket.on('message', (data) => {
if (data.type === 'system') {
addSystemMessage(data.message);
} else if (data.type === 'user') {
addUserMessage(data.username, data.message, data.timestamp);
}
});
socket.on('users_update', (data) => {
updateOnlineCount(data.count);
});
socket.on('error', (data) => {
addSystemMessage(`Error: ${data.message}`, 'error');
});
socket.on('clear_chat', (data) => {
clearAllMessages();
addSystemMessage(data.message || 'Chat cleared by server', 'warning');
});
socket.on('disconnect', () => {
isAuthenticated = false;
console.log('Disconnected from chat server');
addSystemMessage('Disconnected from chat', 'error');
});
socket.on('connect_error', (error) => {
console.error('Connection error:', error);
addSystemMessage('Connection error. Retrying...', 'error');
});
} catch (error) {
console.error('Error connecting to chat:', error);
addSystemMessage('Failed to connect to chat server', 'error');
}
}
function loadSocketIO() {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.socket.io/4.6.1/socket.io.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
function sendMessage() {
const chatInput = document.getElementById('chatInput');
const message = chatInput.value.trim();
if (!message || message.length === 0) {
return;
}
if (message.length > MAX_MESSAGE_LENGTH) {
addSystemMessage(`Message too long (max ${MAX_MESSAGE_LENGTH} characters)`, 'error');
return;
}
if (!socket || !isAuthenticated) {
messageQueue.push(message);
addSystemMessage('Connecting... Your message will be sent soon.', 'warning');
chatInput.value = '';
updateCharCounter();
return;
}
socket.emit('send_message', { message });
chatInput.value = '';
updateCharCounter();
}
function addUserMessage(username, message, timestamp) {
const chatMessages = document.getElementById('chatMessages');
if (!chatMessages) return;
const messageDiv = document.createElement('div');
messageDiv.className = 'chat-message user-message';
const time = new Date(timestamp).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
messageDiv.innerHTML = `
<div class="message-header">
<span class="message-username">${escapeHtml(username)}</span>
<span class="message-time">${time}</span>
</div>
<div class="message-content">${message}</div>
`;
chatMessages.appendChild(messageDiv);
scrollToBottom();
}
function addSystemMessage(message, type = 'info') {
const chatMessages = document.getElementById('chatMessages');
if (!chatMessages) return;
const messageDiv = document.createElement('div');
messageDiv.className = `chat-message system-message system-${type}`;
messageDiv.innerHTML = `
<div class="message-content">
<i class="fas fa-info-circle"></i> ${escapeHtml(message)}
</div>
`;
chatMessages.appendChild(messageDiv);
scrollToBottom();
}
function updateOnlineCount(count) {
const onlineCountElement = document.getElementById('chatOnlineCount');
if (onlineCountElement) {
onlineCountElement.textContent = count;
}
}
function updateCharCounter() {
const chatInput = document.getElementById('chatInput');
const charCounter = document.getElementById('chatCharCounter');
if (chatInput && charCounter) {
const length = chatInput.value.length;
charCounter.textContent = `${length}/${MAX_MESSAGE_LENGTH}`;
if (length > MAX_MESSAGE_LENGTH * 0.9) {
charCounter.classList.add('warning');
} else {
charCounter.classList.remove('warning');
}
}
}
function scrollToBottom() {
const chatMessages = document.getElementById('chatMessages');
if (chatMessages) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
function clearAllMessages() {
const chatMessages = document.getElementById('chatMessages');
if (chatMessages) {
chatMessages.innerHTML = '';
console.log('Chat cleared');
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
window.addEventListener('beforeunload', () => {
if (socket && socket.connected) {
socket.disconnect();
}
});
document.addEventListener('DOMContentLoaded', () => {
const usernameSubmitBtn = document.getElementById('chatUsernameSubmit');
const usernameCancelBtn = document.getElementById('chatUsernameCancel');
const usernameInput = document.getElementById('chatUsernameInput');
if (usernameSubmitBtn) {
usernameSubmitBtn.addEventListener('click', submitChatUsername);
}
if (usernameCancelBtn) {
usernameCancelBtn.addEventListener('click', () => {
hideUsernameModal();
const playNavItem = document.querySelector('[data-page="play"]');
if (playNavItem) playNavItem.click();
});
}
if (usernameInput) {
usernameInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitChatUsername();
}
});
}
const chatNavItem = document.querySelector('[data-page="chat"]');
if (chatNavItem) {
chatNavItem.addEventListener('click', () => {
if (!socket) {
initChat();
}
});
}
});
window.ChatAPI = {
send: sendMessage,
disconnect: () => socket?.disconnect()
};

212
GUI/js/install.js Normal file
View File

@@ -0,0 +1,212 @@
let isDownloading = false;
let installPage;
let installBtn;
let installText;
let installPlayerName;
let installCustomCheck;
let installCustomOptions;
let installPathInput;
export function setupInstallation() {
installPage = document.getElementById('install-page');
installBtn = document.getElementById('installBtn');
installText = document.getElementById('installText');
installPlayerName = document.getElementById('installPlayerName');
installCustomCheck = document.getElementById('installCustomCheck');
installCustomOptions = document.getElementById('installCustomOptions');
installPathInput = document.getElementById('installPath');
if (installCustomCheck && installCustomOptions) {
installCustomCheck.addEventListener('change', (e) => {
if (e.target.checked) {
installCustomOptions.classList.add('show');
} else {
installCustomOptions.classList.remove('show');
}
});
}
if (installPlayerName) {
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() {
if (isDownloading || (installBtn && installBtn.disabled)) return;
const playerName = (installPlayerName ? installPlayerName.value.trim() : '') || 'Player';
const installPath = installPathInput ? installPathInput.value.trim() : '';
if (window.LauncherUI) window.LauncherUI.showProgress();
isDownloading = true;
if (installBtn) {
installBtn.disabled = true;
installText.textContent = 'INSTALLING...';
}
try {
if (window.electronAPI && window.electronAPI.installGame) {
const result = await window.electronAPI.installGame(playerName, '', installPath);
if (result.success) {
if (window.LauncherUI) {
window.LauncherUI.updateProgress({ message: 'Installation completed successfully!' });
setTimeout(() => {
window.LauncherUI.hideProgress();
window.LauncherUI.showLauncherOrInstall(true);
const playerNameInput = document.getElementById('playerName');
if (playerNameInput) playerNameInput.value = playerName;
}, 2000);
}
} else {
throw new Error(result.error || 'Installation failed');
}
} else {
simulateInstallation(playerName);
}
} catch (error) {
if (window.LauncherUI) {
window.LauncherUI.updateProgress({ message: `Installation failed: ${error.message}` });
setTimeout(() => {
window.LauncherUI.hideProgress();
resetInstallButton();
}, 3000);
}
}
}
function simulateInstallation(playerName) {
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 3;
if (progress > 100) progress = 100;
if (window.LauncherUI) {
window.LauncherUI.updateProgress({
percent: progress,
message: progress < 100 ? 'Installing game files...' : 'Installation complete!',
speed: 1024 * 1024 * (5 + Math.random() * 10),
downloaded: progress * 1024 * 1024 * 20,
total: 1024 * 1024 * 2000
});
}
if (progress >= 100) {
clearInterval(interval);
setTimeout(() => {
if (window.LauncherUI) {
window.LauncherUI.updateProgress({ message: 'Installation completed successfully!' });
setTimeout(() => {
window.LauncherUI.hideProgress();
window.LauncherUI.showLauncherOrInstall(true);
const playerNameInput = document.getElementById('playerName');
if (playerNameInput) playerNameInput.value = playerName;
resetInstallButton();
}, 2000);
}
}, 1000);
}
}, 200);
}
function resetInstallButton() {
isDownloading = false;
if (installBtn) {
installBtn.disabled = false;
installText.textContent = 'INSTALL HYTALE';
}
}
export async function browseInstallPath() {
try {
if (window.electronAPI && window.electronAPI.selectInstallPath) {
const result = await window.electronAPI.selectInstallPath();
if (result && installPathInput) {
installPathInput.value = result;
}
}
} catch (error) {
console.error('Error browsing install path:', error);
}
}
async function savePlayerName() {
try {
if (window.electronAPI && window.electronAPI.saveSettings) {
const playerName = (installPlayerName ? installPlayerName.value.trim() : '') || 'Player';
await window.electronAPI.saveSettings({ playerName });
}
} catch (error) {
console.error('Error saving player name:', error);
}
}
export async function checkGameStatusAndShowInterface() {
try {
if (window.electronAPI && window.electronAPI.isGameInstalled) {
const installed = await window.electronAPI.isGameInstalled();
if (window.LauncherUI) {
window.LauncherUI.showLauncherOrInstall(installed);
}
if (installed) {
await loadPlayerSettings();
}
} else {
if (window.LauncherUI) {
window.LauncherUI.showLauncherOrInstall(false);
}
}
} catch (error) {
console.error('Error checking game status:', error);
if (window.LauncherUI) {
window.LauncherUI.showLauncherOrInstall(false);
}
}
}
async function loadPlayerSettings() {
try {
if (window.electronAPI && window.electronAPI.loadSettings) {
const settings = await window.electronAPI.loadSettings();
if (settings) {
const playerNameInput = document.getElementById('playerName');
const javaPathInput = document.getElementById('javaPath');
if (settings.playerName && playerNameInput) {
playerNameInput.value = settings.playerName;
}
if (settings.javaPath && javaPathInput) {
javaPathInput.value = settings.javaPath;
}
}
}
} catch (error) {
console.error('Error loading settings:', error);
}
}
window.installGame = installGame;
window.browseInstallPath = browseInstallPath;
document.addEventListener('DOMContentLoaded', async () => {
setupInstallation();
await checkGameStatusAndShowInterface();
});

244
GUI/js/launcher.js Normal file
View File

@@ -0,0 +1,244 @@
let isDownloading = false;
let playBtn;
let playText;
let homePlayBtn;
let uninstallBtn;
let playerNameInput;
let javaPathInput;
export function setupLauncher() {
playBtn = document.getElementById('playBtn');
playText = document.getElementById('playText');
homePlayBtn = document.getElementById('homePlayBtn');
uninstallBtn = document.getElementById('uninstallBtn');
playerNameInput = document.getElementById('playerName');
javaPathInput = document.getElementById('javaPath');
if (playerNameInput) {
playerNameInput.addEventListener('change', savePlayerName);
}
if (javaPathInput) {
javaPathInput.addEventListener('change', saveJavaPath);
}
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();
}
resetPlayButton();
});
}
}
export async function launch() {
if (isDownloading || (playBtn && playBtn.disabled)) return;
let playerName = 'Player';
if (window.SettingsAPI && window.SettingsAPI.getCurrentPlayerName) {
playerName = window.SettingsAPI.getCurrentPlayerName();
} else if (playerNameInput && playerNameInput.value.trim()) {
playerName = playerNameInput.value.trim();
}
let javaPath = '';
if (window.SettingsAPI && window.SettingsAPI.getCurrentJavaPath) {
javaPath = window.SettingsAPI.getCurrentJavaPath();
}
if (window.LauncherUI) window.LauncherUI.showProgress();
isDownloading = true;
if (playBtn) {
playBtn.disabled = true;
playText.textContent = 'LAUNCHING...';
}
try {
if (window.electronAPI && window.electronAPI.launchGame) {
const result = await window.electronAPI.launchGame(playerName, javaPath, '');
if (result.success) {
if (window.LauncherUI) {
window.LauncherUI.updateProgress({ message: 'Game started successfully!' });
setTimeout(() => {
window.LauncherUI.hideProgress();
if (window.electronAPI.minimizeWindow) {
window.electronAPI.minimizeWindow();
}
}, 2000);
}
} else {
throw new Error(result.error || 'Launch failed');
}
} else {
setTimeout(() => {
if (window.LauncherUI) {
window.LauncherUI.updateProgress({ message: 'Game started successfully!' });
setTimeout(() => {
window.LauncherUI.hideProgress();
resetPlayButton();
}, 2000);
}
}, 2000);
}
} catch (error) {
if (window.LauncherUI) {
window.LauncherUI.updateProgress({ message: `Failed: ${error.message}` });
setTimeout(() => {
window.LauncherUI.hideProgress();
resetPlayButton();
}, 3000);
}
}
}
export async function uninstallGame() {
if (!confirm('Are you sure you want to uninstall Hytale? All game files will be deleted.')) {
return;
}
if (window.LauncherUI) window.LauncherUI.showProgress();
if (window.LauncherUI) window.LauncherUI.updateProgress({ message: 'Uninstalling game...' });
if (uninstallBtn) uninstallBtn.disabled = true;
try {
if (window.electronAPI && window.electronAPI.uninstallGame) {
const result = await window.electronAPI.uninstallGame();
if (result.success) {
if (window.LauncherUI) {
window.LauncherUI.updateProgress({ message: 'Game uninstalled successfully!' });
setTimeout(() => {
window.LauncherUI.hideProgress();
window.LauncherUI.showLauncherOrInstall(false);
}, 2000);
}
} else {
throw new Error(result.error || 'Uninstall failed');
}
} else {
setTimeout(() => {
if (window.LauncherUI) {
window.LauncherUI.updateProgress({ message: 'Game uninstalled successfully!' });
setTimeout(() => {
window.LauncherUI.hideProgress();
window.LauncherUI.showLauncherOrInstall(false);
}, 2000);
}
}, 2000);
}
} catch (error) {
if (window.LauncherUI) {
window.LauncherUI.updateProgress({ message: `Uninstall failed: ${error.message}` });
setTimeout(() => window.LauncherUI.hideProgress(), 3000);
}
} finally {
if (uninstallBtn) uninstallBtn.disabled = false;
}
}
function resetPlayButton() {
isDownloading = false;
if (playBtn) {
playBtn.disabled = false;
playText.textContent = 'PLAY';
}
}
async function savePlayerName() {
try {
if (window.electronAPI && window.electronAPI.saveSettings) {
const playerName = (playerNameInput ? playerNameInput.value.trim() : '') || 'Player';
await window.electronAPI.saveSettings({ playerName });
}
} catch (error) {
console.error('Error saving player name:', error);
}
}
async function saveJavaPath() {
try {
if (window.electronAPI && window.electronAPI.saveSettings) {
const javaPath = (javaPathInput ? javaPathInput.value.trim() : '') || '';
await window.electronAPI.saveSettings({ javaPath });
}
} catch (error) {
console.error('Error saving Java path:', error);
}
}
function toggleCustomJava() {
if (!customJavaOptions) return;
if (customJavaCheck && customJavaCheck.checked) {
customJavaOptions.style.display = 'block';
} else {
customJavaOptions.style.display = 'none';
if (customJavaPath) customJavaPath.value = '';
saveCustomJavaPath('');
}
}
async function browseJavaPath() {
try {
if (window.electronAPI && window.electronAPI.browseJavaPath) {
const result = await window.electronAPI.browseJavaPath();
if (result && result.filePaths && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0];
if (customJavaPath) {
customJavaPath.value = selectedPath;
}
await saveCustomJavaPath(selectedPath);
}
}
} catch (error) {
console.error('Error browsing Java path:', error);
}
}
async function saveCustomJavaPath(path) {
try {
if (window.electronAPI && window.electronAPI.saveJavaPath) {
await window.electronAPI.saveJavaPath(path);
}
} catch (error) {
console.error('Error saving custom Java path:', error);
}
}
async function loadCustomJavaPath() {
try {
if (window.electronAPI && window.electronAPI.loadJavaPath) {
const savedPath = await window.electronAPI.loadJavaPath();
if (savedPath && savedPath.trim()) {
if (customJavaPath) {
customJavaPath.value = savedPath;
}
if (customJavaCheck) {
customJavaCheck.checked = true;
}
if (customJavaOptions) {
customJavaOptions.style.display = 'block';
}
}
}
} catch (error) {
console.error('Error loading custom Java path:', error);
}
}
window.launch = launch;
window.uninstallGame = uninstallGame;
document.addEventListener('DOMContentLoaded', setupLauncher);

724
GUI/js/mods.js Normal file
View File

@@ -0,0 +1,724 @@
const API_KEY = '$2a$10$bqk254NMZOWVTzLVJCcxEOmhcyUujKxA5xk.kQCN9q0KNYFJd5b32';
const CURSEFORGE_API = 'https://api.curseforge.com/v1';
const HYTALE_GAME_ID = 70216;
let installedMods = [];
let browseMods = [];
let searchQuery = '';
let modsPage = 0;
let modsPageSize = 20;
let modsTotalPages = 1;
export async function initModsManager() {
setupModsEventListeners();
await loadInstalledMods();
await loadBrowseMods();
}
function setupModsEventListeners() {
const searchInput = document.getElementById('modsSearch');
if (searchInput) {
let searchTimeout;
searchInput.addEventListener('input', (e) => {
searchQuery = e.target.value.toLowerCase().trim();
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
modsPage = 0;
loadBrowseMods();
}, 500);
});
}
const myModsBtn = document.getElementById('myModsBtn');
if (myModsBtn) {
myModsBtn.addEventListener('click', openMyModsModal);
}
const closeModalBtn = document.getElementById('closeMyModsModal');
if (closeModalBtn) {
closeModalBtn.addEventListener('click', closeMyModsModal);
}
const modal = document.getElementById('myModsModal');
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeMyModsModal();
}
});
}
const prevPageBtn = document.getElementById('prevPage');
const nextPageBtn = document.getElementById('nextPage');
if (prevPageBtn) {
prevPageBtn.addEventListener('click', () => {
if (modsPage > 0) {
modsPage--;
loadBrowseMods();
}
});
}
if (nextPageBtn) {
nextPageBtn.addEventListener('click', () => {
if (modsPage < modsTotalPages - 1) {
modsPage++;
loadBrowseMods();
}
});
}
}
function openMyModsModal() {
const modal = document.getElementById('myModsModal');
if (modal) {
modal.classList.add('active');
loadInstalledMods();
}
}
function closeMyModsModal() {
const modal = document.getElementById('myModsModal');
if (modal) {
modal.classList.remove('active');
}
}
async function loadInstalledMods() {
try {
const modsPath = await window.electronAPI?.getModsPath();
if (!modsPath) {
showInstalledModsError('Could not get mods directory');
return;
}
const mods = await window.electronAPI?.loadInstalledMods(modsPath);
installedMods = mods || [];
displayInstalledMods(installedMods);
} catch (error) {
console.error('Error loading installed mods:', error);
showInstalledModsError('Failed to load installed mods');
}
}
function displayInstalledMods(mods) {
const modsContainer = document.getElementById('installedModsList');
if (!modsContainer) return;
if (mods.length === 0) {
modsContainer.innerHTML = `
<div class=\"empty-installed-mods\">
<i class=\"fas fa-box-open\"></i>
<h4>No Mods Installed</h4>
<p>Add mods from CurseForge or import local files</p>
</div>
`;
return;
}
modsContainer.innerHTML = mods.map(mod => createInstalledModCard(mod)).join('');
mods.forEach(mod => {
const toggleBtn = document.getElementById(`toggle-installed-${mod.id}`);
const deleteBtn = document.getElementById(`delete-installed-${mod.id}`);
if (toggleBtn) {
toggleBtn.addEventListener('click', () => toggleMod(mod.id));
}
if (deleteBtn) {
deleteBtn.addEventListener('click', () => deleteMod(mod.id));
}
});
}
function createInstalledModCard(mod) {
const statusClass = mod.enabled ? 'text-primary' : 'text-zinc-500';
const statusText = mod.enabled ? 'ACTIVE' : 'DISABLED';
const toggleBtnClass = mod.enabled ? 'btn-disable' : 'btn-enable';
const toggleBtnText = mod.enabled ? 'DISABLE' : 'ENABLE';
const toggleIcon = mod.enabled ? 'fa-pause' : 'fa-play';
return `
<div class="installed-mod-card" data-mod-id="${mod.id}">
<div class="installed-mod-icon">
<i class="fas fa-cube"></i>
</div>
<div class="installed-mod-info">
<div class="installed-mod-header">
<h4 class="installed-mod-name">${mod.name}</h4>
<span class="installed-mod-version">v${mod.version}</span>
</div>
<p class="installed-mod-description">${mod.description || 'No description available'}</p>
</div>
<div class="installed-mod-actions">
<div class="installed-mod-status ${statusClass}">
<i class="fas fa-circle"></i>
${statusText}
</div>
<div class="installed-mod-buttons">
<button id="delete-installed-${mod.id}" class="installed-mod-btn-icon" title="Delete mod">
<i class="fas fa-trash"></i>
</button>
<button id="toggle-installed-${mod.id}" class="installed-mod-btn-toggle ${toggleBtnClass}">
<i class="fas ${toggleIcon}"></i>
${toggleBtnText}
</button>
</div>
</div>
</div>
`;
}
async function loadBrowseMods() {
const browseContainer = document.getElementById('browseModsList');
if (!browseContainer) return;
browseContainer.innerHTML = '<div class=\"loading-mods\"><div class=\"loading-spinner\"></div><span>Loading mods from CurseForge...</span></div>';
try {
if (!API_KEY || API_KEY.length < 10) {
browseContainer.innerHTML = `
<div class=\"empty-browse-mods\">
<i class=\"fas fa-key\"></i>
<h4>API Key Required</h4>
<p>CurseForge API key is needed to browse mods</p>
</div>
`;
return;
}
const offset = modsPage * modsPageSize;
let url = `${CURSEFORGE_API}/mods/search?gameId=${HYTALE_GAME_ID}&pageSize=${modsPageSize}&sortOrder=desc&sortField=6&index=${offset}`;
if (searchQuery && searchQuery.length > 0) {
url += `&searchFilter=${encodeURIComponent(searchQuery)}`;
}
console.log('Fetching mods from page', modsPage + 1, 'offset:', offset, 'search:', searchQuery || 'none', 'URL:', url);
const response = await fetch(url, {
headers: {
'x-api-key': API_KEY,
'Accept': 'application/json'
}
});
console.log('Response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('API Error Response:', errorText);
throw new Error(`CurseForge API error: ${response.status} - ${errorText}`);
}
const data = await response.json();
console.log('API Response data:', data);
console.log('Total mods found:', data.data?.length || 0);
browseMods = (data.data || []).map(mod => ({
id: mod.id.toString(),
name: mod.name,
slug: mod.slug,
summary: mod.summary || 'No description available',
downloadCount: mod.downloadCount || 0,
author: mod.authors?.[0]?.name || 'Unknown',
version: mod.latestFiles?.[0]?.displayName || 'Unknown',
thumbnailUrl: mod.logo?.thumbnailUrl || null,
websiteUrl: mod.links?.websiteUrl || null,
modId: mod.id,
fileId: mod.latestFiles?.[0]?.id,
fileName: mod.latestFiles?.[0]?.fileName,
downloadUrl: mod.latestFiles?.[0]?.downloadUrl
}));
console.log('Processed mods:', browseMods.length);
modsTotalPages = Math.ceil((data.pagination?.totalCount || 1) / modsPageSize);
displayBrowseMods(browseMods);
updatePagination();
} catch (error) {
console.error('Error loading browse mods:', error);
browseContainer.innerHTML = `
<div class=\"empty-browse-mods error\">
<i class=\"fas fa-exclamation-triangle\"></i>
<h4>API Error</h4>
<p>Failed to load mods from CurseForge</p>
<small>${error.message}</small>
</div>
`;
}
}
function displayBrowseMods(mods) {
const browseContainer = document.getElementById('browseModsList');
if (!browseContainer) return;
if (mods.length === 0) {
browseContainer.innerHTML = `
<div class=\"empty-browse-mods\">
<i class=\"fas fa-search\"></i>
<h4>No Mods Found</h4>
<p>Try adjusting your search</p>
</div>
`;
return;
}
browseContainer.innerHTML = mods.map(mod => createBrowseModCard(mod)).join('');
mods.forEach(mod => {
const installBtn = document.getElementById(`install-${mod.id}`);
if (installBtn) {
installBtn.addEventListener('click', () => downloadAndInstallMod(mod));
}
});
}
function createBrowseModCard(mod) {
const isInstalled = installedMods.some(installed =>
installed.name.toLowerCase().includes(mod.name.toLowerCase()) ||
installed.curseForgeId == mod.id
);
return `
<div class=\"mod-card ${isInstalled ? 'installed' : ''}\" data-mod-id=\"${mod.id}\">
<div class=\"mod-image\">
${mod.thumbnailUrl ?
`<img src=\"${mod.thumbnailUrl}\" alt=\"${mod.name}\" onerror=\"this.parentElement.innerHTML='<i class=\\\"fas fa-puzzle-piece\\\"></i>'\">` :
`<i class=\"fas fa-puzzle-piece\"></i>`
}
</div>
<div class=\"mod-info\">
<div class=\"mod-header\">
<h3 class=\"mod-name\">${mod.name}</h3>
<span class=\"mod-version\">${mod.version}</span>
</div>
<p class=\"mod-description\">${mod.summary}</p>
<div class=\"mod-meta\">
<span class=\"mod-meta-item\">
<i class=\"fas fa-user\"></i>
${mod.author}
</span>
<span class=\"mod-meta-item\">
<i class=\"fas fa-download\"></i>
${formatNumber(mod.downloadCount)}
</span>
</div>
</div>
<div class=\"mod-actions\">
<button id=\"view-${mod.id}\" class=\"mod-btn-toggle bg-blue-600 text-white hover:bg-blue-700\" onclick=\"window.modsManager.viewModPage(${mod.id})\">
<i class=\"fas fa-external-link-alt\"></i>
VIEW
</button>
${!isInstalled ?
`<button id=\"install-${mod.id}\" class=\"mod-btn-toggle bg-primary text-black hover:bg-primary/80\">
<i class=\"fas fa-download\"></i>
INSTALL
</button>` :
`<button class=\"mod-btn-toggle bg-white/10 text-white\" disabled>
<i class=\"fas fa-check\"></i>
INSTALLED
</button>`
}
</div>
</div>
`;
}
async function downloadAndInstallMod(modInfo) {
try {
window.LauncherUI?.showProgress(`Downloading ${modInfo.name}...`);
const result = await window.electronAPI?.downloadMod(modInfo);
if (result?.success) {
const newMod = {
id: result.modInfo.id,
name: modInfo.name,
version: modInfo.version,
description: modInfo.summary,
author: modInfo.author,
enabled: true,
fileName: result.fileName,
fileSize: result.modInfo.fileSize,
dateInstalled: new Date().toISOString(),
curseForgeId: modInfo.modId,
curseForgeFileId: modInfo.fileId
};
installedMods.push(newMod);
await loadInstalledMods();
await loadBrowseMods();
window.LauncherUI?.hideProgress();
showNotification(`${modInfo.name} installed successfully! 🎉`, 'success');
} else {
throw new Error(result?.error || 'Failed to download mod');
}
} catch (error) {
console.error('Error downloading mod:', error);
window.LauncherUI?.hideProgress();
showNotification('Failed to download mod: ' + error.message, 'error');
}
}
async function toggleMod(modId) {
try {
window.LauncherUI?.showProgress('Toggling mod...');
const modsPath = await window.electronAPI?.getModsPath();
const result = await window.electronAPI?.toggleMod(modId, modsPath);
if (result?.success) {
await loadInstalledMods();
window.LauncherUI?.hideProgress();
} else {
throw new Error(result?.error || 'Failed to toggle mod');
}
} catch (error) {
console.error('Error toggling mod:', error);
window.LauncherUI?.hideProgress();
showNotification('Failed to toggle mod: ' + error.message, 'error');
}
}
async function deleteMod(modId) {
const mod = installedMods.find(m => m.id === modId);
if (!mod) return;
showConfirmModal(
`Are you sure you want to delete "${mod.name}"? This action cannot be undone.`,
async () => {
try {
window.LauncherUI?.showProgress('Deleting mod...');
const modsPath = await window.electronAPI?.getModsPath();
const result = await window.electronAPI?.uninstallMod(modId, modsPath);
if (result?.success) {
await loadInstalledMods();
await loadBrowseMods();
window.LauncherUI?.hideProgress();
showNotification(`"${mod.name}" deleted successfully`, 'success');
} else {
throw new Error(result?.error || 'Failed to delete mod');
}
} catch (error) {
console.error('Error deleting mod:', error);
window.LauncherUI?.hideProgress();
showNotification('Failed to delete mod: ' + error.message, 'error');
}
}
);
}
function formatNumber(num) {
if (!num) return '0';
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num.toString();
}
function showNotification(message, type = 'info', duration = 4000) {
const existing = document.querySelector(`.mod-notification.${type}`);
if (existing) {
existing.remove();
}
const notification = document.createElement('div');
notification.className = `mod-notification ${type}`;
const icons = {
success: 'fa-check-circle',
error: 'fa-exclamation-circle',
info: 'fa-info-circle',
warning: 'fa-exclamation-triangle'
};
const colors = {
success: '#10b981',
error: '#ef4444',
info: '#3b82f6',
warning: '#f59e0b'
};
notification.innerHTML = `
<div class="notification-content">
<i class="fas ${icons[type]}"></i>
<span>${message}</span>
</div>
<button class="notification-close" onclick="this.parentElement.remove()">
<i class="fas fa-times"></i>
</button>
`;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${colors[type]};
color: white;
padding: 16px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 10000;
min-width: 300px;
max-width: 400px;
transform: translateX(100%);
transition: transform 0.3s ease;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 500;
`;
const contentStyle = `
display: flex;
align-items: center;
gap: 10px;
flex: 1;
`;
const closeStyle = `
background: none;
border: none;
color: white;
cursor: pointer;
padding: 4px;
border-radius: 4px;
opacity: 0.8;
transition: opacity 0.2s;
margin-left: 10px;
`;
notification.querySelector('.notification-content').style.cssText = contentStyle;
notification.querySelector('.notification-close').style.cssText = closeStyle;
document.body.appendChild(notification);
// Animate in
setTimeout(() => {
notification.style.transform = 'translateX(0)';
}, 10);
// Auto remove
setTimeout(() => {
if (notification.parentElement) {
notification.style.transform = 'translateX(100%)';
setTimeout(() => {
notification.remove();
}, 300);
}
}, duration);
}
// Custom confirmation modal
function showConfirmModal(message, onConfirm, onCancel = null) {
const existingModal = document.querySelector('.mod-confirm-modal');
if (existingModal) {
existingModal.remove();
}
const modal = document.createElement('div');
modal.className = 'mod-confirm-modal';
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
z-index: 20000;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
`;
const dialog = document.createElement('div');
dialog.className = 'mod-confirm-dialog';
dialog.style.cssText = `
background: #1f2937;
border-radius: 12px;
padding: 0;
min-width: 400px;
max-width: 500px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6);
border: 1px solid rgba(239, 68, 68, 0.3);
transform: scale(0.9);
transition: transform 0.3s ease;
`;
dialog.innerHTML = `
<div style="padding: 24px; border-bottom: 1px solid rgba(255,255,255,0.1);">
<div style="display: flex; align-items: center; gap: 12px; color: #ef4444;">
<i class="fas fa-exclamation-triangle" style="font-size: 24px;"></i>
<h3 style="margin: 0; font-size: 1.2rem; font-weight: 600;">Confirm Deletion</h3>
</div>
</div>
<div style="padding: 24px; color: #e5e7eb;">
<p style="margin: 0; line-height: 1.5; font-size: 1rem;">${message}</p>
</div>
<div style="padding: 20px 24px; display: flex; gap: 12px; justify-content: flex-end; border-top: 1px solid rgba(255,255,255,0.1);">
<button class="mod-confirm-cancel" style="
background: transparent;
color: #9ca3af;
border: 1px solid rgba(156, 163, 175, 0.3);
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
">Cancel</button>
<button class="mod-confirm-delete" style="
background: #ef4444;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
">Delete</button>
</div>
`;
modal.appendChild(dialog);
document.body.appendChild(modal);
// Animate in
setTimeout(() => {
modal.style.opacity = '1';
dialog.style.transform = 'scale(1)';
}, 10);
// Event handlers
const cancelBtn = dialog.querySelector('.mod-confirm-cancel');
const deleteBtn = dialog.querySelector('.mod-confirm-delete');
const closeModal = () => {
modal.style.opacity = '0';
dialog.style.transform = 'scale(0.9)';
setTimeout(() => {
modal.remove();
}, 300);
};
cancelBtn.onclick = () => {
closeModal();
if (onCancel) onCancel();
};
deleteBtn.onclick = () => {
closeModal();
onConfirm();
};
modal.onclick = (e) => {
if (e.target === modal) {
closeModal();
if (onCancel) onCancel();
}
};
// Escape key
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeModal();
if (onCancel) onCancel();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
}
function updatePagination() {
const currentPageEl = document.getElementById('currentPage');
const totalPagesEl = document.getElementById('totalPages');
const prevBtn = document.getElementById('prevPage');
const nextBtn = document.getElementById('nextPage');
if (currentPageEl) currentPageEl.textContent = modsPage + 1;
if (totalPagesEl) totalPagesEl.textContent = modsTotalPages;
if (prevBtn) {
prevBtn.disabled = modsPage === 0;
prevBtn.style.opacity = modsPage === 0 ? '0.5' : '1';
prevBtn.style.cursor = modsPage === 0 ? 'not-allowed' : 'pointer';
}
if (nextBtn) {
nextBtn.disabled = modsPage >= modsTotalPages - 1;
nextBtn.style.opacity = modsPage >= modsTotalPages - 1 ? '0.5' : '1';
nextBtn.style.cursor = modsPage >= modsTotalPages - 1 ? 'not-allowed' : 'pointer';
}
}
function showInstalledModsError(message) {
const modsContainer = document.getElementById('installedModsList');
if (!modsContainer) return;
modsContainer.innerHTML = `
<div class=\"empty-installed-mods error\">
<i class=\"fas fa-exclamation-triangle\"></i>
<h4>Error</h4>
<p>${message}</p>
</div>
`;
}
function viewModPage(modId) {
console.log('Looking for mod with ID:', modId, 'Type:', typeof modId);
console.log('Available mods:', browseMods.map(m => ({ id: m.id, name: m.name, type: typeof m.id })));
const mod = browseMods.find(m => m.id.toString() === modId.toString());
if (mod) {
console.log('Found mod:', mod.name);
let modUrl;
if (mod.websiteUrl && mod.websiteUrl.includes('curseforge.com')) {
modUrl = mod.websiteUrl;
} else if (mod.slug) {
modUrl = `https://www.curseforge.com/hytale/mods/${mod.slug}`;
} else {
const nameSlug = mod.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
modUrl = `https://www.curseforge.com/hytale/mods/${nameSlug}`;
}
console.log('Opening URL:', modUrl);
if (window.electronAPI && window.electronAPI.openExternalLink) {
window.electronAPI.openExternalLink(modUrl);
} else {
if (window.electronAPI && window.electronAPI.shell) {
window.electronAPI.shell.openExternal(modUrl);
} else {
window.open(modUrl, '_blank');
}
}
} else {
console.error('Mod not found with ID:', modId);
showNotification('Mod information not found', 'error');
}
}
window.modsManager = {
toggleMod,
deleteMod,
openMyModsModal,
closeMyModsModal,
viewModPage
};
document.addEventListener('DOMContentLoaded', initModsManager);

124
GUI/js/news.js Normal file
View File

@@ -0,0 +1,124 @@
let newsData = [];
export async function loadNews() {
try {
if (window.electronAPI && window.electronAPI.getHytaleNews) {
try {
const realNews = await window.electronAPI.getHytaleNews();
if (realNews && realNews.length > 0) {
newsData = realNews.slice(0, 10).map((article, index) => ({
id: index + 1,
title: article.title,
summary: article.description,
type: "NEWS",
image: article.imageUrl || '',
date: formatDate(article.date),
url: article.destUrl
}));
displayHomeNews(newsData.slice(0, 5));
displayFullNews(newsData);
} else {
showErrorNews();
}
} catch (error) {
console.log('Failed to load news:', error.message);
showErrorNews();
}
} else {
showErrorNews();
}
} catch (error) {
console.error('Error loading news:', error);
showErrorNews();
}
}
function displayHomeNews(news) {
const newsGrid = document.getElementById('newsGrid');
if (!newsGrid) return;
newsGrid.innerHTML = news.map(article => `
<div class="news-item news-card" onclick="openNewsDetails(${article.id})">
<div class="news-image" style="background-image: url('${article.image}');"></div>
<div class="news-overlay">
<span class="news-type">${article.type}</span>
<span class="news-date">${article.date}</span>
</div>
<div class="news-content">
<h3 class="news-title">${article.title}</h3>
<p class="news-summary">${article.summary}</p>
</div>
</div>
`).join('');
}
function displayFullNews(news) {
const allNewsGrid = document.getElementById('allNewsGrid');
if (!allNewsGrid) return;
allNewsGrid.innerHTML = news.map(article => `
<div class="news-item news-card" onclick="openNewsDetails(${article.id})">
<div class="news-image" style="background-image: url('${article.image}');"></div>
<div class="news-overlay">
<span class="news-type">${article.type}</span>
<span class="news-date">${article.date}</span>
</div>
<div class="news-content">
<h3 class="news-title">${article.title}</h3>
<p class="news-summary">${article.summary}</p>
</div>
</div>
`).join('');
}
function showErrorNews() {
const newsGrid = document.getElementById('newsGrid');
if (newsGrid) {
newsGrid.innerHTML = `
<div class="loading-news">
<i class="fas fa-exclamation-triangle text-4xl mb-4 text-yellow-500"></i>
<span>Unable to load news</span>
</div>
`;
}
}
function openNewsDetails(newsId) {
const article = newsData.find(item => item.id === newsId);
if (article && article.url) {
openNewsArticle(article.url);
} else {
console.log('Opening news article:', article);
}
}
function openNewsArticle(url) {
if (url && url !== '#' && window.electronAPI && window.electronAPI.openExternal) {
window.electronAPI.openExternal(url);
}
}
function formatDate(dateString) {
if (!dateString) return 'RECENTLY';
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 1) return '1 DAY AGO';
if (diffDays < 7) return `${diffDays} DAYS AGO`;
if (diffDays < 30) return `${Math.ceil(diffDays / 7)} WEEKS AGO`;
return date.toLocaleDateString();
}
window.openNewsDetails = openNewsDetails;
window.navigateToPage = (page) => {
if (window.LauncherUI) {
window.LauncherUI.showPage(`${page}-page`);
window.LauncherUI.setActiveNav(page);
}
};
document.addEventListener('DOMContentLoaded', loadNews);

154
GUI/js/players.js Normal file
View File

@@ -0,0 +1,154 @@
const API_URL = 'http://3.10.208.30/api';
let updateInterval = null;
let currentUserId = null;
export async function initPlayersCounter() {
setupPlayersCounter();
if (window.electronAPI && window.electronAPI.getUserId) {
currentUserId = await window.electronAPI.getUserId();
} else {
console.error('Electron API not available');
return;
}
let username = 'Player';
if (window.electronAPI.loadUsername) {
const savedUsername = await window.electronAPI.loadUsername();
if (savedUsername) username = savedUsername;
}
await registerPlayer(username, currentUserId);
await fetchPlayerStats();
startAutoUpdate();
}
function setupPlayersCounter() {
const counterElement = document.getElementById('playersOnlineCounter');
if (!counterElement) {
console.warn('Players counter element not found');
}
}
async function fetchPlayerStats() {
try {
const response = await fetch(`${API_URL}/players/stats`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
updateCounterDisplay(data);
} catch (error) {
console.error('Error fetching player stats:', error);
updateCounterDisplay({ online: 0, peak: 0 });
}
}
function updateCounterDisplay(stats) {
const counterElement = document.getElementById('playersOnlineCounter');
const onlineCount = document.getElementById('onlineCount');
if (onlineCount) {
onlineCount.textContent = stats.online || 0;
}
if (counterElement) {
counterElement.classList.add('updated');
setTimeout(() => {
counterElement.classList.remove('updated');
}, 300);
}
}
async function registerPlayer(username, userId) {
try {
const response = await fetch(`${API_URL}/players/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, userId })
});
if (!response.ok) {
throw new Error(`Failed to register player: ${response.status}`);
}
const data = await response.json();
currentUserId = userId;
console.log('Player registered:', data);
await fetchPlayerStats();
return data;
} catch (error) {
console.error('Error registering player:', error);
return null;
}
}
async function unregisterPlayer(userId) {
try {
const response = await fetch(`${API_URL}/players/unregister`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ userId })
});
if (!response.ok) {
throw new Error(`Failed to unregister player: ${response.status}`);
}
const data = await response.json();
currentUserId = null;
console.log('Player unregistered:', data);
await fetchPlayerStats();
return data;
} catch (error) {
console.error('Error unregistering player:', error);
return null;
}
}
function startAutoUpdate() {
updateInterval = setInterval(async () => {
await fetchPlayerStats();
if (currentUserId) {
const username = window.LauncherState?.username || 'Player';
await registerPlayer(username, currentUserId);
}
}, 3000);
}
function stopAutoUpdate() {
if (updateInterval) {
clearInterval(updateInterval);
updateInterval = null;
}
}
window.addEventListener('beforeunload', () => {
if (currentUserId) {
const data = JSON.stringify({ userId: currentUserId });
navigator.sendBeacon(`${API_URL}/players/unregister`, data);
}
stopAutoUpdate();
});
window.PlayersAPI = {
register: registerPlayer,
unregister: unregisterPlayer,
fetchStats: fetchPlayerStats
};
document.addEventListener('DOMContentLoaded', initPlayersCounter);

42
GUI/js/script.js Normal file
View File

@@ -0,0 +1,42 @@
import './ui.js';
import './install.js';
import './launcher.js';
import './news.js';
import './mods.js';
import './players.js';
import './chat.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();
};

155
GUI/js/settings.js Normal file
View File

@@ -0,0 +1,155 @@
let customJavaCheck;
let customJavaOptions;
let customJavaPath;
let browseJavaBtn;
let settingsPlayerName;
export function initSettings() {
setupSettingsElements();
loadAllSettings();
}
function setupSettingsElements() {
customJavaCheck = document.getElementById('customJavaCheck');
customJavaOptions = document.getElementById('customJavaOptions');
customJavaPath = document.getElementById('customJavaPath');
browseJavaBtn = document.getElementById('browseJavaBtn');
settingsPlayerName = document.getElementById('settingsPlayerName');
if (customJavaCheck) {
customJavaCheck.addEventListener('change', toggleCustomJava);
}
if (browseJavaBtn) {
browseJavaBtn.addEventListener('click', browseJavaPath);
}
if (settingsPlayerName) {
settingsPlayerName.addEventListener('change', savePlayerName);
}
}
function toggleCustomJava() {
if (!customJavaOptions) return;
if (customJavaCheck && customJavaCheck.checked) {
customJavaOptions.style.display = 'block';
} else {
customJavaOptions.style.display = 'none';
if (customJavaPath) customJavaPath.value = '';
saveCustomJavaPath('');
}
}
async function browseJavaPath() {
try {
if (window.electronAPI && window.electronAPI.browseJavaPath) {
const result = await window.electronAPI.browseJavaPath();
if (result && result.filePaths && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0];
if (customJavaPath) {
customJavaPath.value = selectedPath;
}
await saveCustomJavaPath(selectedPath);
}
}
} catch (error) {
console.error('Error browsing Java path:', error);
}
}
async function saveCustomJavaPath(path) {
try {
if (window.electronAPI && window.electronAPI.saveJavaPath) {
await window.electronAPI.saveJavaPath(path);
}
} catch (error) {
console.error('Error saving custom Java path:', error);
}
}
async function loadCustomJavaPath() {
try {
if (window.electronAPI && window.electronAPI.loadJavaPath) {
const savedPath = await window.electronAPI.loadJavaPath();
if (savedPath && savedPath.trim()) {
if (customJavaPath) {
customJavaPath.value = savedPath;
}
if (customJavaCheck) {
customJavaCheck.checked = true;
}
if (customJavaOptions) {
customJavaOptions.style.display = 'block';
}
}
}
} catch (error) {
console.error('Error loading custom Java path:', error);
}
}
async function savePlayerName() {
try {
if (window.electronAPI && window.electronAPI.saveUsername && settingsPlayerName) {
const playerName = settingsPlayerName.value.trim() || 'Player';
await window.electronAPI.saveUsername(playerName);
}
} catch (error) {
console.error('Error saving player name:', error);
}
}
async function loadPlayerName() {
try {
if (window.electronAPI && window.electronAPI.loadUsername && settingsPlayerName) {
const savedName = await window.electronAPI.loadUsername();
if (savedName) {
settingsPlayerName.value = savedName;
}
}
} catch (error) {
console.error('Error loading player name:', error);
}
}
async function loadAllSettings() {
await loadCustomJavaPath();
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() {
if (customJavaCheck && customJavaCheck.checked && customJavaPath) {
return customJavaPath.value.trim();
}
return '';
}
export function getCurrentPlayerName() {
if (settingsPlayerName && settingsPlayerName.value.trim()) {
return settingsPlayerName.value.trim();
}
return 'Player';
}
// Make openGameLocation globally available
window.openGameLocation = openGameLocation;
document.addEventListener('DOMContentLoaded', initSettings);
window.SettingsAPI = {
getCurrentJavaPath,
getCurrentPlayerName
};

498
GUI/js/ui.js Normal file
View File

@@ -0,0 +1,498 @@
let progressOverlay;
let progressBar;
let progressBarFill;
let progressText;
let progressPercent;
let progressSpeed;
let progressSize;
function showPage(pageId) {
const pages = document.querySelectorAll('.page');
pages.forEach(page => {
if (page.id === pageId) {
page.classList.add('active');
page.style.display = '';
} else {
page.classList.remove('active');
page.style.display = 'none';
}
});
}
function setActiveNav(page) {
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => {
if (item.getAttribute('data-page') === page) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
}
function handleNavigation() {
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => {
item.addEventListener('click', () => {
const page = item.getAttribute('data-page');
showPage(`${page}-page`);
setActiveNav(page);
});
});
}
function setupWindowControls() {
const minimizeBtn = document.querySelector('.window-controls .minimize');
const closeBtn = document.querySelector('.window-controls .close');
const windowControls = document.querySelector('.window-controls');
const header = document.querySelector('.header');
if (windowControls) {
windowControls.style.pointerEvents = 'auto';
windowControls.style.zIndex = '10000';
}
if (header) {
header.style.webkitAppRegion = 'drag';
if (windowControls) {
windowControls.style.webkitAppRegion = 'no-drag';
}
}
if (window.electronAPI) {
if (minimizeBtn) {
minimizeBtn.onclick = (e) => {
e.stopPropagation();
window.electronAPI.minimizeWindow();
};
}
if (closeBtn) {
closeBtn.onclick = (e) => {
e.stopPropagation();
window.electronAPI.closeWindow();
};
}
}
}
function showLauncherOrInstall(isInstalled) {
const launcher = document.getElementById('launcher-container');
const install = document.getElementById('install-page');
const sidebar = document.querySelector('.sidebar');
const gameTitle = document.querySelector('.game-title-section');
if (isInstalled) {
if (launcher) launcher.style.display = '';
if (install) install.style.display = 'none';
if (sidebar) sidebar.style.pointerEvents = 'auto';
if (gameTitle) gameTitle.style.display = '';
showPage('play-page');
setActiveNav('play');
} else {
if (launcher) launcher.style.display = 'none';
if (install) {
install.style.display = '';
install.classList.add('active');
}
if (sidebar) sidebar.style.pointerEvents = 'none';
if (gameTitle) gameTitle.style.display = 'none';
const pages = document.querySelectorAll('#launcher-container .page');
pages.forEach(page => page.classList.remove('active'));
}
}
function setupSidebarLogo() {
const logo = document.querySelector('.sidebar-logo img');
if (logo) {
logo.addEventListener('click', () => {
showPage('play-page');
setActiveNav('play');
});
}
}
function showProgress() {
if (progressOverlay) {
progressOverlay.style.display = 'block';
setTimeout(() => {
progressOverlay.style.opacity = '1';
progressOverlay.style.transform = 'translateY(0)';
}, 10);
}
}
function hideProgress() {
if (progressOverlay) {
progressOverlay.style.opacity = '0';
progressOverlay.style.transform = 'translateY(20px)';
setTimeout(() => {
progressOverlay.style.display = 'none';
}, 300);
}
}
function updateProgress(data) {
if (data.message && progressText) {
progressText.textContent = data.message;
}
if (data.percent !== null && data.percent !== undefined) {
const percent = Math.min(100, Math.max(0, Math.round(data.percent)));
if (progressPercent) progressPercent.textContent = `${percent}%`;
if (progressBarFill) progressBarFill.style.width = `${percent}%`;
if (progressBar) progressBar.style.width = `${percent}%`;
}
if (data.speed && data.downloaded && data.total) {
const speedMB = (data.speed / 1024 / 1024).toFixed(2);
const downloadedMB = (data.downloaded / 1024 / 1024).toFixed(2);
const totalMB = (data.total / 1024 / 1024).toFixed(2);
if (progressSpeed) progressSpeed.textContent = `${speedMB} MB/s`;
if (progressSize) progressSize.textContent = `${downloadedMB} / ${totalMB} MB`;
}
}
function setupAnimations() {
document.body.style.opacity = '0';
document.body.style.transform = 'translateY(20px)';
setTimeout(() => {
document.body.style.transition = 'all 0.6s ease';
document.body.style.opacity = '1';
document.body.style.transform = 'translateY(0)';
}, 100);
const style = document.createElement('style');
style.textContent = `
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
document.head.appendChild(style);
}
function setupFirstLaunchHandlers() {
console.log('Setting up first launch handlers...');
window.electronAPI.onFirstLaunchUpdate((data) => {
console.log('Received first launch update event:', data);
showFirstLaunchUpdateDialog(data);
});
window.electronAPI.onFirstLaunchWelcome(() => {
});
window.electronAPI.onFirstLaunchProgress((data) => {
showProgress();
updateProgress(data);
});
let lockButtonTimeout = null;
window.electronAPI.onLockPlayButton((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;
}
}
});
}
function showFirstLaunchUpdateDialog(data) {
console.log('Creating first launch modal...');
const existingModal = document.querySelector('.first-launch-modal-overlay');
if (existingModal) {
existingModal.remove();
}
const modalOverlay = document.createElement('div');
modalOverlay.className = 'first-launch-modal-overlay';
modalOverlay.style.cssText = `
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
background: rgba(0, 0, 0, 0.95) !important;
backdrop-filter: blur(10px) !important;
z-index: 999999 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
pointer-events: all !important;
`;
const modalDialog = document.createElement('div');
modalDialog.className = 'first-launch-modal-dialog';
modalDialog.style.cssText = `
background: #1a1a1a !important;
border-radius: 12px !important;
padding: 0 !important;
width: 500px !important;
max-width: 90vw !important;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.8) !important;
border: 1px solid rgba(147, 51, 234, 0.5) !important;
overflow: hidden !important;
animation: modalSlideIn 0.3s ease-out !important;
`;
modalDialog.innerHTML = `
<div style="background: linear-gradient(135deg, rgba(147, 51, 234, 0.2), rgba(59, 130, 246, 0.2)); padding: 25px; border-bottom: 1px solid rgba(255,255,255,0.1);">
<h2 style="margin: 0; color: #fff; font-size: 1.5rem; font-weight: 600; text-align: center;">
🔄 Game Update Required
</h2>
</div>
<div style="padding: 30px; color: #e5e7eb; line-height: 1.6;">
<div style="text-align: center; margin-bottom: 25px;">
<p style="font-size: 1.1rem; margin-bottom: 15px;">
An existing Hytale installation has been detected and must be updated to the latest version.
</p>
<p style="color: #10b981; font-weight: 500; margin-bottom: 20px;">
✅ Your game saves and settings will be preserved
</p>
</div>
<div style="background: rgba(59, 130, 246, 0.1); padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6; margin: 20px 0;">
<p style="margin: 8px 0; font-family: 'Courier New', monospace; font-size: 0.9em;">
<strong>📁 Location:</strong> ${data.existingGame.installPath}
</p>
<p style="margin: 8px 0; font-family: 'Courier New', monospace; font-size: 0.9em;">
<strong>💾 UserData:</strong> ${data.existingGame.hasUserData ? '✅ Found (will be preserved)' : '❌ Not found'}
</p>
</div>
<div style="background: rgba(234, 179, 8, 0.1); padding: 15px; border-radius: 8px; border-left: 4px solid #eab308; margin: 20px 0;">
<p style="margin: 0; color: #fbbf24; font-weight: 500; font-size: 0.95em;">
⚠️ This update is mandatory and cannot be skipped
</p>
</div>
</div>
<div style="padding: 25px; border-top: 1px solid rgba(255,255,255,0.1); text-align: center;">
<button id="updateGameBtn" style="
background: linear-gradient(135deg, #9333ea, #3b82f6) !important;
color: white !important;
border: none !important;
padding: 15px 30px !important;
border-radius: 8px !important;
font-size: 1rem !important;
font-weight: 600 !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
min-width: 200px !important;
" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">
🚀 Update Game Now
</button>
</div>
`;
modalOverlay.appendChild(modalDialog);
modalOverlay.onclick = (e) => {
if (e.target === modalOverlay) {
e.preventDefault();
e.stopPropagation();
return false;
}
};
document.addEventListener('keydown', function preventEscape(e) {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
return false;
}
});
document.body.appendChild(modalOverlay);
const updateBtn = document.getElementById('updateGameBtn');
updateBtn.onclick = () => {
acceptFirstLaunchUpdate();
};
window.firstLaunchExistingGame = data.existingGame;
console.log('First launch modal created and displayed');
}
function lockPlayButton(locked) {
const playButton = document.getElementById('homePlayBtn');
if (!playButton) {
console.warn('Play button not found');
return;
}
if (locked) {
playButton.style.opacity = '0.5';
playButton.style.pointerEvents = 'none';
playButton.style.cursor = 'not-allowed';
playButton.setAttribute('data-locked', 'true');
const spanElement = playButton.querySelector('span');
if (spanElement) {
if (!playButton.getAttribute('data-original-text')) {
playButton.setAttribute('data-original-text', spanElement.textContent);
}
spanElement.textContent = 'CHECKING...';
}
console.log('Play button locked');
} else {
playButton.style.opacity = '';
playButton.style.pointerEvents = '';
playButton.style.cursor = '';
playButton.removeAttribute('data-locked');
const spanElement = playButton.querySelector('span');
const originalText = playButton.getAttribute('data-original-text');
if (spanElement && originalText) {
spanElement.textContent = originalText;
playButton.removeAttribute('data-original-text');
}
console.log('Play button unlocked');
}
}
async function acceptFirstLaunchUpdate() {
const existingGame = window.firstLaunchExistingGame;
if (!existingGame) {
showNotification('Error: Game data not found', 'error');
return;
}
const modal = document.querySelector('.first-launch-modal-overlay');
if (modal) {
modal.style.pointerEvents = 'none';
const btn = document.getElementById('updateGameBtn');
if (btn) {
btn.style.opacity = '0.5';
btn.style.cursor = 'not-allowed';
btn.textContent = '🔄 Updating...';
}
}
try {
showProgress();
updateProgress({ message: 'Starting mandatory game update...', percent: 0 });
const result = await window.electronAPI.acceptFirstLaunchUpdate(existingGame);
window.electronAPI.markAsLaunched && window.electronAPI.markAsLaunched();
if (modal) {
modal.remove();
}
lockPlayButton(false);
if (result.success) {
hideProgress();
showNotification('Game updated successfully! 🎉', 'success');
} else {
hideProgress();
showNotification(`Update failed: ${result.error}`, 'error');
}
} catch (error) {
if (modal) {
modal.remove();
}
lockPlayButton(false);
hideProgress();
showNotification(`Update error: ${error.message}`, 'error');
}
}
function dismissFirstLaunchDialog() {
const modal = document.querySelector('.first-launch-modal-overlay');
if (modal) {
modal.remove();
}
lockPlayButton(false);
window.electronAPI.markAsLaunched && window.electronAPI.markAsLaunched();
}
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('show');
}, 100);
setTimeout(() => {
notification.remove();
}, 5000);
}
function setupUI() {
progressOverlay = document.getElementById('progressOverlay');
progressBar = document.getElementById('progressBar');
progressBarFill = document.getElementById('progressBarFill');
progressText = document.getElementById('progressText');
progressPercent = document.getElementById('progressPercent');
progressSpeed = document.getElementById('progressSpeed');
progressSize = document.getElementById('progressSize');
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();
setupWindowControls();
setupSidebarLogo();
setupAnimations();
setupFirstLaunchHandlers();
document.body.focus();
}
window.LauncherUI = {
showPage,
setActiveNav,
showLauncherOrInstall,
showProgress,
hideProgress,
updateProgress
};
document.addEventListener('DOMContentLoaded', setupUI);

162
GUI/js/update.js Normal file
View File

@@ -0,0 +1,162 @@
class ClientUpdateManager {
constructor() {
this.updatePopupVisible = false;
this.init();
}
init() {
window.electronAPI.onUpdatePopup((updateInfo) => {
this.showUpdatePopup(updateInfo);
});
this.checkForUpdatesOnDemand();
}
showUpdatePopup(updateInfo) {
if (this.updatePopupVisible) return;
this.updatePopupVisible = true;
const popupHTML = `
<div id="update-popup-overlay">
<div class="update-popup-container update-popup-pulse">
<div class="update-popup-header">
<div class="update-popup-icon">
<i class="fas fa-download"></i>
</div>
<h2 class="update-popup-title">
NEW UPDATE AVAILABLE
</h2>
</div>
<div class="update-popup-versions">
<div class="version-row">
<span class="version-label">Current Version:</span>
<span class="version-current">${updateInfo.currentVersion}</span>
</div>
<div class="version-row">
<span class="version-label">New Version:</span>
<span class="version-new">${updateInfo.newVersion}</span>
</div>
</div>
<div class="update-popup-message">
A new version of Hytale F2P Launcher is available.<br>
Please download the latest version to continue using the launcher.
</div>
<button id="update-download-btn" class="update-download-btn">
<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>
Download Update
</button>
<div class="update-popup-footer">
This popup cannot be closed until you update the launcher
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', popupHTML);
this.blockInterface();
const downloadBtn = document.getElementById('update-download-btn');
if (downloadBtn) {
downloadBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
downloadBtn.disabled = true;
downloadBtn.innerHTML = '<i class="fas fa-spinner fa-spin" style="margin-right: 0.5rem;"></i>Opening GitHub...';
try {
await window.electronAPI.openDownloadPage();
console.log('✅ Download page opened, launcher will close...');
downloadBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Launcher closing...';
} catch (error) {
console.error('❌ Error opening download page:', error);
downloadBtn.disabled = false;
downloadBtn.innerHTML = '<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>Download Update';
}
});
}
const overlay = document.getElementById('update-popup-overlay');
if (overlay) {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
e.preventDefault();
e.stopPropagation();
return false;
}
});
}
console.log('🔔 Update popup displayed with new style');
}
blockInterface() {
const mainContent = document.querySelector('.flex.w-full.h-screen');
if (mainContent) {
mainContent.classList.add('interface-blocked');
}
document.body.classList.add('no-select');
document.addEventListener('keydown', this.blockKeyEvents.bind(this), true);
document.addEventListener('contextmenu', this.blockContextMenu.bind(this), true);
console.log('🚫 Interface blocked for update');
}
blockKeyEvents(event) {
if (event.target.closest('#update-popup-overlay')) {
if ((event.key === 'Enter' || event.key === ' ') &&
event.target.id === 'update-download-btn') {
return;
}
if (event.key !== 'Tab') {
event.preventDefault();
event.stopPropagation();
}
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
return false;
}
blockContextMenu(event) {
if (!event.target.closest('#update-popup-overlay')) {
event.preventDefault();
event.stopPropagation();
return false;
}
}
async checkForUpdatesOnDemand() {
try {
const updateInfo = await window.electronAPI.checkForUpdates();
if (updateInfo.updateAvailable) {
this.showUpdatePopup(updateInfo);
}
return updateInfo;
} catch (error) {
console.error('Error checking for updates:', error);
return { updateAvailable: false, error: error.message };
}
}
}
document.addEventListener('DOMContentLoaded', () => {
window.updateManager = new ClientUpdateManager();
});
window.ClientUpdateManager = ClientUpdateManager;

4333
GUI/style.css Normal file

File diff suppressed because it is too large Load Diff

9
Hytale-F2P.desktop Normal file
View File

@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Hytale-F2P
Comment=A modern, cross-platform launcher for Hytale with automatic updates and multi-client support
Exec=/opt/Hytale-F2P/hytale-f2p-launcher
Categories=Game;
Icon=Hytale-F2P
Terminal=false
StartupNotify=true

31
PKGBUILD Normal file
View File

@@ -0,0 +1,31 @@
# Maintainer: Terromur <terromuroz@proton.me>
pkgname=Hytale-F2P-git
_pkgname=Hytale-F2P
pkgver=2.0.0.r47.gebcfdc4
pkgrel=1
pkgdesc="HyLauncher - unofficial Hytale Launcher for free to play gamers"
arch=('x86_64')
url="https://github.com/amiayweb/Hytale-F2P"
license=('custom')
makedepends=('npm')
source=("git+$url.git" "Hytale-F2P.desktop")
sha256sums=('SKIP' '46488fada4775d9976d7b7b62f8d1f1f8d9a9a9d8f8aa9af4f2e2153019f6a30')
pkgver() {
cd "$_pkgname"
printf "2.0.0.r%s.g%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
build() {
cd "$_pkgname"
npm install
npm run build:linux
}
package() {
mkdir -p "$pkgdir/opt/$_pkgname"
cp -r "$_pkgname/dist/linux-unpacked/"* "$pkgdir/opt/$_pkgname"
install -Dm644 "$_pkgname.desktop" "$pkgdir/usr/share/applications/$_pkgname.desktop"
install -Dm644 "$_pkgname/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/$_pkgname.png"
}

207
README.md
View File

@@ -1,41 +1,198 @@
# Hytale F2P Launcher # 🎮 Hytale F2P Launcher | Multiplayer Support
A simple offline launcher for Hytale that allows you to play the game for free. <div align="center">
## Features ![Version](https://img.shields.io/badge/Version-2.0.1-green?style=for-the-badge)
![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey?style=for-the-badge)
![License](https://img.shields.io/badge/License-Educational-blue?style=for-the-badge)
- Offline gameplay support **A modern, cross-platform offline launcher for Hytale with automatic updates and multiplayer support (windows users & non-premium only)**
- Automatic game file download and installation
- Java runtime management
- Clean and modern interface
## Installation [![GitHub stars](https://img.shields.io/github/stars/amiayweb/Hytale-F2P?style=social)](https://github.com/amiayweb/Hytale-F2P/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/amiayweb/Hytale-F2P?style=social)](https://github.com/amiayweb/Hytale-F2P/network/members)
### Windows **If you find this project useful, please give it a star!**
🛑 **Found a problem? Open an issue! Im on Windows, so I cant test on macOS or Linux.** 🛑
1. Download the latest release from the [Releases](https://github.com/amiayweb/Hytale-F2P/releases) page </div>
2. Run `Hytale F2P Launcher Setup.exe` to install
3. Launch the application from your desktop or start menu
### Linux ---
## 📸 Screenshots
See [BUILD.md](BUILD.md) for detailed build instructions. <div align="center">
### macOS ![Hytale F2P Launcher](https://i.imgur.com/9iDuzST.png)
![Hytale F2P Mods](https://i.imgur.com/NaareIS.png)
![Hytale F2P News](https://i.imgur.com/n1nEqRS.png)
![Hytale F2P Chat](https://i.imgur.com/Y4hL3sx.png)
See [BUILD.md](BUILD.md) for detailed build instructions. </div>
## Usage ---
## ✨ Features
1. Enter your player name 🎯 **Core Features**
2. Click "PLAY" - 🔄 **Automatic Updates** - Smart version checking and seamless game updates
3. The launcher will automatically download and install required files - 💾 **Data Preservation** - Intelligent UserData backup and restoration during updates
4. The game will launch automatically - 🌐 **Cross-Platform** - Full support for Windows, Linux (X11/Wayland), and macOS
-**Java Management** - Automatic Java runtime detection and installation
- 🎮 **Multiplayer Support** - Automatic multiplayer client installation (Windows)
## Building from Source 🛡️ **Advanced Features**
- 📁 **Custom Installation** - Choose your own installation directory
- 🔍 **Smart Detection** - Automatic game and dependency detection
- 🗂️ **Mod Support** - Built-in mod management system
- 💬 **Player Chat** - Integrated chat system for community interaction
- 📰 **News Feed** - Stay updated with the latest Hytale news
- 🎨 **Modern UI** - Clean, responsive interface with dark theme
See [BUILD.md](BUILD.md) for detailed build instructions. ---
## 🚀 Quick Start
### 📥 Installation
#### Windows
1. Download the latest `Hytale-F2P.exe` from [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases)
2. Run the installer
3. Launch from desktop or start menu
#### Linux
See [BUILD.md](BUILD.md) for detailed build instructions or [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases) section.
#### macOS
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)?
See [SERVER.md](SERVER.md)
---
## 🛠️ Building from Source
See [BUILD.md](BUILD.md) for comprehensive build instructions.
---
## 📋 Changelog
### 🆕 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
-**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
- 🐧 **Enhanced Linux Support** - Full Wayland and X11 compatibility
- 🔄 **Multiplayer Auto-Install** - Automatic multiplayer client setup on updates (Windows)
- 📡 **API Integration** - Real-time version checking and client management
- 🎨 **UI Improvements** - Added contributor credits footer
- 🔄 **Complete Launcher Overhaul** - Total redesign of the launcher architecture and interface
- 🗂️ **Integrated Mod Manager** - Built-in mod installation, management
- 💬 **Community Chat System** - Real-time chat for launcher users to connect and communicate
### 🔧 v1.0.1
- 📁 **Custom Installation** - Choose installation directory with file browser
- 🏠 **Always on Top** - Launcher stays visible during installation
- 🧠 **Smart Detection** - Automatic game detection and UI adaptation
- 🗑️ **Uninstall Feature** - Easy game removal with one click
- 🔄 **Dynamic UI** - "INSTALL" vs "PLAY" button based on game state
- 🛠️ **Path Management** - Proper custom directory handling
- 💫 **UI Polish** - Improved layout and overflow prevention
### 🎉 v1.0.0 *(Initial Release)*
- 🎮 **Offline Gameplay** - Play Hytale without internet connection
-**Auto Installation** - One-click game setup
-**Java Management** - Automatic Java runtime handling
- 🎨 **Modern Interface** - Clean, intuitive design
- 🌟 **First Release** - Core launcher functionality
---
## 👥 Contributors
<div align="center">
**Made with ❤️ by the community**
[![Contributors](https://contrib.rocks/image?repo=amiayweb/Hytale-F2P)](https://github.com/amiayweb/Hytale-F2P/graphs/contributors)
</div>
### 🏆 Project Creator
- [**@amiayweb**](https://github.com/amiayweb) - *Lead Developer & Project Creator*
### 🌟 Contributors
- [**@chasem-dev**](https://github.com/chasem-dev) - *Issues fixer*
- [**@crimera**](https://github.com/crimera) - *Issues fixer*
- [**@sanasol**](https://github.com/sanasol) - *Issues fixer*
- [**@Terromur**](https://github.com/Terromur) - *Issues fixer*
- [**@Citeli-py**](https://github.com/Citeli-py) - *Issues fixer*
- [**@ericiskoolbeans**](https://github.com/ericiskoolbeans) - *Beta Tester*
---
## 📊 GitHub Stats
<div align="center">
![GitHub stars](https://img.shields.io/github/stars/amiayweb/Hytale-F2P?style=for-the-badge&logo=github)
![GitHub forks](https://img.shields.io/github/forks/amiayweb/Hytale-F2P?style=for-the-badge&logo=github)
![GitHub issues](https://img.shields.io/github/issues/amiayweb/Hytale-F2P?style=for-the-badge&logo=github)
![GitHub downloads](https://img.shields.io/github/downloads/amiayweb/Hytale-F2P/total?style=for-the-badge&logo=github)
</div>
## 📞 Support
<div align="center">
[![GitHub Issues](https://img.shields.io/badge/GitHub-Issues-red?style=for-the-badge&logo=github)](https://github.com/amiayweb/Hytale-F2P/issues)
[![Discussions](https://img.shields.io/badge/GitHub-Discussions-blue?style=for-the-badge&logo=github)](https://github.com/amiayweb/Hytale-F2P/discussions)
**Need help?** Open an [issue](https://github.com/amiayweb/Hytale-F2P/issues) or start a [discussion](https://github.com/amiayweb/Hytale-F2P/discussions)!
</div>
---
## ⚖️ Legal Disclaimer
<div align="center">
⚠️ **Important Notice** ⚠️
</div>
This launcher is created for **educational purposes only**.
🏛️ **Not Official** - This is an independent fan project **not affiliated with, endorsed by, or associated with** Hypixel Studios or Hytale.
🛡️ **No Warranty** - This software is provided **"as is"** without any warranty of any kind.
📝 **Responsibility** - The authors take no responsibility for how this software is used.
🛑 **Takedown Policy** - If Hypixel Studios or Hytale requests removal, this project will be taken down immediately.
❤️ **Support Official** - Please support the official game by purchasing it when available.
---
<div align="center">
**⭐ Star this project if you found it helpful! ⭐**
*Made with ❤️ by [@amiayweb](https://github.com/amiayweb) and the amazing community*
[![Star History Chart](https://api.star-history.com/svg?repos=amiayweb/Hytale-F2P&type=date&legend=top-left)](https://www.star-history.com/#amiayweb/Hytale-F2P&type=date&legend=top-left)
</div>
## Disclaimer
This launcher is for educational purposes only.

87
SERVER.md Normal file
View 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
View 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
View 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
};

136
backend/launcher.js Normal file
View File

@@ -0,0 +1,136 @@
// Main launcher module - orchestrates all launcher functionality
// This file serves as the main entry point and re-exports all necessary functions
// Core modules
const {
saveUsername,
loadUsername,
saveChatUsername,
loadChatUsername,
saveJavaPath,
loadJavaPath,
saveInstallPath,
loadInstallPath,
saveModsToConfig,
loadModsFromConfig,
getUuidForUser,
isFirstLaunch,
markAsLaunched,
CONFIG_FILE
} = require('./core/config');
const { getResolvedAppDir, getModsPath } = require('./core/paths');
// Managers
const {
isGameInstalled,
installGame,
uninstallGame,
updateGameFiles,
checkExistingGameInstallation
} = require('./managers/gameManager');
const {
launchGame,
launchGameWithVersionCheck
} = require('./managers/gameLauncher');
const { getJavaDetection } = require('./managers/javaManager');
const { checkAndInstallMultiClient } = require('./managers/multiClientManager');
const {
downloadAndReplaceHomePageUI,
findHomePageUIPath,
downloadAndReplaceLogo,
findLogoPath
} = require('./managers/uiFileManager');
const {
loadInstalledMods,
downloadMod,
uninstallMod,
toggleMod
} = require('./managers/modManager');
// Services
const {
getInstalledClientVersion,
getLatestClientVersion
} = require('./services/versionManager');
const { getHytaleNews } = require('./services/newsManager');
const { getOrCreatePlayerId } = require('./services/playerManager');
const {
proposeGameUpdate,
handleFirstLaunchCheck
} = require('./services/firstLaunch');
// Re-export all functions to maintain backward compatibility
module.exports = {
// Game launch functions
launchGame,
launchGameWithVersionCheck,
// Game installation functions
installGame,
isGameInstalled,
uninstallGame,
updateGameFiles,
// User configuration functions
saveUsername,
loadUsername,
saveChatUsername,
loadChatUsername,
getUuidForUser,
// Java configuration functions
saveJavaPath,
loadJavaPath,
getJavaDetection,
// Installation path functions
saveInstallPath,
loadInstallPath,
// Version functions
getInstalledClientVersion,
getLatestClientVersion,
// News functions
getHytaleNews,
// Player ID functions
getOrCreatePlayerId,
// Multi-client functions
checkAndInstallMultiClient,
// Mod management functions
getModsPath,
loadInstalledMods,
downloadMod,
uninstallMod,
toggleMod,
saveModsToConfig,
loadModsFromConfig,
// UI file management functions
downloadAndReplaceHomePageUI,
findHomePageUIPath,
downloadAndReplaceLogo,
findLogoPath,
// First launch functions
isFirstLaunch,
markAsLaunched,
checkExistingGameInstallation,
proposeGameUpdate,
handleFirstLaunchCheck,
// Path functions
getResolvedAppDir
};

213
backend/logger.js Normal file
View 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;

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

View 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
};

73
backend/updateManager.js Normal file
View File

@@ -0,0 +1,73 @@
const axios = require('axios');
const UPDATE_CHECK_URL = 'http://3.10.208.30:3002/api/version_launcher';
const CURRENT_VERSION = '2.0.1';
const GITHUB_DOWNLOAD_URL = 'https://github.com/amiayweb/Hytale-F2P/';
class UpdateManager {
constructor() {
this.updateAvailable = false;
this.remoteVersion = null;
}
async checkForUpdates() {
try {
console.log('Checking for updates...');
console.log(`Local version: ${CURRENT_VERSION}`);
const response = await axios.get(UPDATE_CHECK_URL, {
timeout: 5000,
headers: {
'User-Agent': 'Hytale-F2P-Launcher'
}
});
if (response.data && response.data.launcher_version) {
this.remoteVersion = response.data.launcher_version;
console.log(`Remote version: ${this.remoteVersion}`);
if (this.remoteVersion !== CURRENT_VERSION) {
this.updateAvailable = true;
console.log('Update available!');
return {
updateAvailable: true,
currentVersion: CURRENT_VERSION,
newVersion: this.remoteVersion,
downloadUrl: GITHUB_DOWNLOAD_URL
};
} else {
console.log('Launcher is up to date');
return {
updateAvailable: false,
currentVersion: CURRENT_VERSION,
newVersion: this.remoteVersion
};
}
} else {
throw new Error('Invalid API response');
}
} catch (error) {
console.error('Error checking for updates:', error.message);
return {
updateAvailable: false,
error: error.message,
currentVersion: CURRENT_VERSION
};
}
}
getDownloadUrl() {
return GITHUB_DOWNLOAD_URL;
}
getUpdateInfo() {
return {
updateAvailable: this.updateAvailable,
currentVersion: CURRENT_VERSION,
remoteVersion: this.remoteVersion,
downloadUrl: this.getDownloadUrl()
};
}
}
module.exports = UpdateManager;

View 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();

View 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
};

View 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
};

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

681
main.js Normal file
View File

@@ -0,0 +1,681 @@
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
const path = require('path');
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 UpdateManager = require('./backend/updateManager');
const logger = require('./backend/logger');
logger.interceptConsole();
let mainWindow;
let updateManager;
let discordRPC = null;
// Discord Rich Presence setup
const DISCORD_CLIENT_ID = '1462244937868513373';
function initDiscordRPC() {
try {
const { Client } = require('discord-rpc');
discordRPC = new Client({ transport: 'ipc' });
discordRPC.on('ready', () => {
console.log('Discord RPC connected');
setDiscordActivity();
});
discordRPC.on('disconnected', () => {
console.log('Discord RPC disconnected');
});
discordRPC.login({ clientId: DISCORD_CLIENT_ID }).catch(err => {
console.log('Failed to connect to Discord:', err.message);
});
} catch (error) {
console.log('Discord RPC module not available:', error.message);
}
}
function setDiscordActivity() {
if (!discordRPC) return;
try {
discordRPC.setActivity({
details: 'Using HytaleF2P',
startTimestamp: Date.now(),
largeImageKey: 'hytale_logo',
largeImageText: 'Hytale F2P Launcher',
buttons: [
{
label: 'GitHub',
url: 'https://github.com/amiayweb/Hytale-F2P'
}
]
});
} catch (error) {
console.error('Failed to set Discord activity:', error.message);
}
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 720,
frame: false,
resizable: false,
alwaysOnTop: false,
backgroundColor: '#090909',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
devTools: true,
webSecurity: true
}
});
mainWindow.loadFile('GUI/index.html');
// Initialize Discord Rich Presence
initDiscordRPC();
updateManager = new UpdateManager();
setTimeout(async () => {
const updateInfo = await updateManager.checkForUpdates();
if (updateInfo.updateAvailable) {
mainWindow.webContents.send('show-update-popup', updateInfo);
}
}, 3000);
mainWindow.webContents.on('devtools-opened', () => {
mainWindow.webContents.closeDevTools();
});
mainWindow.webContents.on('before-input-event', (event, input) => {
if (input.control && input.shift && input.key.toLowerCase() === 'i') {
event.preventDefault();
}
if (input.control && input.shift && input.key.toLowerCase() === 'j') {
event.preventDefault();
}
if (input.control && input.shift && input.key.toLowerCase() === 'c') {
event.preventDefault();
}
if (input.key === 'F12') {
event.preventDefault();
}
if (input.key === 'F5') {
event.preventDefault();
}
});
mainWindow.webContents.on('context-menu', (e) => {
e.preventDefault();
});
mainWindow.webContents.setIgnoreMenuShortcuts(true);
}
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();
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 {
console.log('Starting first launch check...');
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('lock-play-button', true);
}
const progressCallback = (message, percent, speed, downloaded, total) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('first-launch-progress', { message, percent, speed, downloaded, total });
}
};
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);
if (mainWindow && !mainWindow.isDestroyed()) {
if (firstLaunchResult.needsUpdate && firstLaunchResult.existingGame) {
console.log('Sending show-first-launch-update event...');
setTimeout(() => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('show-first-launch-update', {
existingGame: firstLaunchResult.existingGame,
isFirstLaunch: firstLaunchResult.isFirstLaunch
});
}
}, 1000);
} else if (firstLaunchResult.isFirstLaunch && !firstLaunchResult.existingGame) {
console.log('Sending show-first-launch-welcome event...');
setTimeout(() => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('show-first-launch-welcome');
}
}, 1000);
} else {
unlockPlayButton();
}
}
} catch (error) {
clearTimeout(timeoutId);
console.error('Error during first launch check:', error);
if (!timeoutReached) {
unlockPlayButton();
}
}
}, 3000);
});
app.on('window-all-closed', () => {
console.log('=== LAUNCHER CLOSING ===');
// Clean up Discord RPC connection
if (discordRPC) {
try {
discordRPC.destroy();
} catch (error) {
console.log('Error cleaning up Discord RPC:', error.message);
}
}
if (process.platform !== 'darwin') {
app.quit();
}
});
ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath) => {
try {
const progressCallback = (message, percent, speed, downloaded, total) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const data = {
message: message || null,
percent: percent !== null && percent !== undefined ? Math.min(100, Math.max(0, percent)) : null,
speed: speed !== null && speed !== undefined ? speed : null,
downloaded: downloaded !== null && downloaded !== undefined ? downloaded : null,
total: total !== null && total !== undefined ? total : null
};
mainWindow.webContents.send('progress-update', data);
}
};
const result = await launchGameWithVersionCheck(playerName, progressCallback, javaPath, installPath);
if (mainWindow && !mainWindow.isDestroyed()) {
setTimeout(() => {
mainWindow.webContents.send('progress-complete');
}, 2000);
}
return result;
} catch (error) {
console.error('Launch error:', error);
const errorMessage = error.message || error.toString();
return { success: false, error: errorMessage };
}
});
ipcMain.handle('install-game', async (event, playerName, javaPath, installPath) => {
try {
const progressCallback = (message, percent, speed, downloaded, total) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const data = {
message: message || null,
percent: percent !== null && percent !== undefined ? Math.min(100, Math.max(0, percent)) : null,
speed: speed !== null && speed !== undefined ? speed : null,
downloaded: downloaded !== null && downloaded !== undefined ? downloaded : null,
total: total !== null && total !== undefined ? total : null
};
mainWindow.webContents.send('progress-update', data);
}
};
const result = await installGame(playerName, progressCallback, javaPath, installPath);
if (mainWindow && !mainWindow.isDestroyed()) {
setTimeout(() => {
mainWindow.webContents.send('progress-complete');
}, 1000);
}
return result;
} catch (error) {
console.error('Install error:', error);
const errorMessage = error.message || error.toString();
return { success: false, error: errorMessage };
}
});
ipcMain.handle('save-username', (event, username) => {
saveUsername(username);
return { success: true };
});
ipcMain.handle('load-username', () => {
return loadUsername();
});
ipcMain.handle('save-chat-username', async (event, chatUsername) => {
saveChatUsername(chatUsername);
});
ipcMain.handle('load-chat-username', async () => {
return loadChatUsername();
});
ipcMain.handle('save-java-path', (event, javaPath) => {
saveJavaPath(javaPath);
return { success: true };
});
ipcMain.handle('load-java-path', () => {
return loadJavaPath();
});
ipcMain.handle('save-install-path', (event, installPath) => {
saveInstallPath(installPath);
logger.updateInstallPath();
return { success: true };
});
ipcMain.handle('load-install-path', () => {
return loadInstallPath();
});
ipcMain.handle('select-install-path', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory'],
title: 'Select Installation Folder'
});
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0];
}
return null;
});
ipcMain.handle('accept-first-launch-update', async (event, existingGame) => {
try {
const progressCallback = (message, percent, speed, downloaded, total) => {
if (mainWindow && !mainWindow.isDestroyed()) {
const data = {
message: message || null,
percent: percent !== null && percent !== undefined ? Math.min(100, Math.max(0, percent)) : null,
speed: speed !== null && speed !== undefined ? speed : null,
downloaded: downloaded !== null && downloaded !== undefined ? downloaded : null,
total: total !== null && total !== undefined ? total : null
};
mainWindow.webContents.send('first-launch-progress', data);
}
};
const result = await proposeGameUpdate(existingGame, progressCallback);
return result;
} catch (error) {
console.error('First launch update error:', error);
const errorMessage = error.message || error.toString();
return { success: false, error: errorMessage };
}
});
ipcMain.handle('mark-as-launched', async () => {
try {
markAsLaunched();
return { success: true };
} catch (error) {
console.error('Mark as launched error:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('is-game-installed', async () => {
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 () => {
try {
await uninstallGame();
return { success: true };
} catch (error) {
console.error('Uninstall error:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('get-hytale-news', async () => {
try {
const news = await getHytaleNews();
return news;
} catch (error) {
console.error('News fetch error:', error);
return [];
}
});
ipcMain.handle('open-external', async (event, url) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
console.error('Failed to open external URL:', error);
return { success: false, error: error.message };
}
});
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 () => {
const isWindows = process.platform === 'win32';
const isMac = process.platform === 'darwin';
let dialogOptions;
if (isWindows) {
dialogOptions = {
properties: ['openFile'],
title: 'Select Java Executable',
filters: [
{ name: 'Java Executable', extensions: ['exe'] },
{ name: 'All Files', extensions: ['*'] }
]
};
} else if (isMac) {
dialogOptions = {
properties: ['openFile'],
title: 'Select Java Executable',
message: 'Select java executable (usually in /Library/Java/JavaVirtualMachines/*/Contents/Home/bin/java)',
filters: [
{ name: 'All Files', extensions: ['*'] }
]
};
} else {
dialogOptions = {
properties: ['openFile'],
title: 'Select Java Executable',
message: 'Select java executable (usually /usr/bin/java or similar)',
filters: [
{ name: 'All Files', extensions: ['*'] }
]
};
}
const result = await dialog.showOpenDialog(mainWindow, dialogOptions);
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0];
}
return null;
});
ipcMain.handle('save-settings', async (event, settings) => {
try {
if (settings.playerName) saveUsername(settings.playerName);
if (settings.javaPath !== undefined) saveJavaPath(settings.javaPath);
if (settings.installPath !== undefined) {
saveInstallPath(settings.installPath);
logger.updateInstallPath();
}
return { success: true };
} catch (error) {
console.error('Save settings error:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('load-settings', async () => {
try {
return {
playerName: loadUsername() || 'Player',
javaPath: loadJavaPath() || '',
installPath: loadInstallPath() || '',
customInstall: false
};
} catch (error) {
console.error('Load settings error:', error);
return {
playerName: 'Player',
javaPath: '',
installPath: '',
customInstall: false
};
}
});
const { getModsPath, loadInstalledMods, downloadMod, uninstallMod, toggleMod } = require('./backend/launcher');
const os = require('os');
ipcMain.handle('get-local-app-data', async () => {
return process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
});
ipcMain.handle('get-user-id', async () => {
try {
const { getOrCreatePlayerId } = require('./backend/launcher');
return await getOrCreatePlayerId();
} catch (error) {
console.error('Error getting user ID:', error);
return null;
}
});
ipcMain.handle('load-installed-mods', async (event, modsPath) => {
try {
return await loadInstalledMods(modsPath);
} catch (error) {
console.error('Error loading installed mods:', error);
return [];
}
});
ipcMain.handle('openExternalLink', async (event, url) => {
try {
console.log('Opening external URL:', url);
await shell.openExternal(url);
return { success: true };
} catch (error) {
console.error('Error opening external link:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('download-mod', async (event, modInfo) => {
try {
return await downloadMod(modInfo);
} catch (error) {
console.error('Error downloading mod:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('uninstall-mod', async (event, modId, modsPath) => {
try {
return await uninstallMod(modId, modsPath);
} catch (error) {
console.error('Error uninstalling mod:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('toggle-mod', async (event, modId, modsPath) => {
try {
return await toggleMod(modId, modsPath);
} catch (error) {
console.error('Error toggling mod:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('get-mods-path', async () => {
try {
return await getModsPath();
} catch (error) {
console.error('Error getting mods path:', error);
return null;
}
});
ipcMain.handle('select-mod-files', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile', 'multiSelections'],
title: 'Select Mod Files',
filters: [
{ name: 'Mod Files', extensions: ['jar', 'zip'] },
{ name: 'All Files', extensions: ['*'] }
]
});
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths;
}
return null;
});
ipcMain.handle('copy-mod-file', async (event, sourcePath, modsPath) => {
try {
const fileName = path.basename(sourcePath);
const destPath = path.join(modsPath, fileName);
fs.copyFileSync(sourcePath, destPath);
return { success: true, fileName };
} catch (error) {
console.error('Error copying mod file:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('check-for-updates', async () => {
try {
return await updateManager.checkForUpdates();
} catch (error) {
console.error('Error checking for updates:', error);
return { updateAvailable: false, error: error.message };
}
});
ipcMain.handle('open-download-page', async () => {
try {
await shell.openExternal(updateManager.getDownloadUrl());
setTimeout(() => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.close();
}
}, 1000);
return { success: true };
} catch (error) {
console.error('Error opening download page:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('get-update-info', async () => {
return updateManager.getUpdateInfo();
});
ipcMain.handle('window-close', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.close();
}
});
ipcMain.handle('window-minimize', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.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;
}
});

4955
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

124
package.json Normal file
View File

@@ -0,0 +1,124 @@
{
"name": "hytale-f2p-launcher",
"version": "2.0.1",
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
"homepage": "https://github.com/amiayweb/Hytale-F2P",
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "electron . --dev",
"build": "electron-builder",
"build:win": "electron-builder --win",
"build:linux": "electron-builder --linux",
"build:mac": "electron-builder --mac",
"build:all": "electron-builder --win --linux --mac"
},
"keywords": [
"hytale",
"launcher",
"game",
"client",
"cross-platform",
"electron",
"auto-update",
"mod-manager",
"chat"
],
"author": {
"name": "AMIAY",
"email": "support@amiay.dev"
},
"license": "MIT",
"devDependencies": {
"electron": "^40.0.0",
"electron-builder": "^26.4.0"
},
"dependencies": {
"adm-zip": "^0.5.10",
"axios": "^1.6.0",
"discord-rpc": "^4.0.1",
"tar": "^6.2.1",
"uuid": "^9.0.1"
},
"overrides": {
"tar": "$tar"
},
"build": {
"appId": "com.hytalef2p.launcher",
"productName": "Hytale F2P",
"directories": {
"output": "dist"
},
"files": [
"main.js",
"preload.js",
"backend/**/*",
"GUI/**/*",
"package.json"
],
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
},
{
"target": "portable",
"arch": [
"x64"
]
}
],
"icon": "icon.ico"
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": [
"x64"
]
},
{
"target": "deb",
"arch": [
"x64"
]
}
],
"icon": "build/icon.png",
"category": "Game",
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support"
},
"mac": {
"target": [
{
"target": "dmg",
"arch": [
"x64",
"arm64"
]
},
{
"target": "zip",
"arch": [
"universal"
]
}
],
"icon": "build/icon.icns",
"category": "public.app-category.games"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
}
}

65
preload.js Normal file
View File

@@ -0,0 +1,65 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
launchGame: (playerName, javaPath, installPath) => ipcRenderer.invoke('launch-game', playerName, javaPath, installPath),
installGame: (playerName, javaPath, installPath) => ipcRenderer.invoke('install-game', playerName, javaPath, installPath),
closeWindow: () => ipcRenderer.invoke('window-close'),
minimizeWindow: () => ipcRenderer.invoke('window-minimize'),
saveUsername: (username) => ipcRenderer.invoke('save-username', username),
loadUsername: () => ipcRenderer.invoke('load-username'),
saveChatUsername: (chatUsername) => ipcRenderer.invoke('save-chat-username', chatUsername),
loadChatUsername: () => ipcRenderer.invoke('load-chat-username'),
saveJavaPath: (javaPath) => ipcRenderer.invoke('save-java-path', javaPath),
loadJavaPath: () => ipcRenderer.invoke('load-java-path'),
saveInstallPath: (installPath) => ipcRenderer.invoke('save-install-path', installPath),
loadInstallPath: () => ipcRenderer.invoke('load-install-path'),
selectInstallPath: () => ipcRenderer.invoke('select-install-path'),
browseJavaPath: () => ipcRenderer.invoke('browse-java-path'),
isGameInstalled: () => ipcRenderer.invoke('is-game-installed'),
uninstallGame: () => ipcRenderer.invoke('uninstall-game'),
getHytaleNews: () => ipcRenderer.invoke('get-hytale-news'),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
openExternalLink: (url) => ipcRenderer.invoke('openExternalLink', url),
openGameLocation: () => ipcRenderer.invoke('open-game-location'),
saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings),
loadSettings: () => ipcRenderer.invoke('load-settings'),
getLocalAppData: () => ipcRenderer.invoke('get-local-app-data'),
getModsPath: () => ipcRenderer.invoke('get-mods-path'),
loadInstalledMods: (modsPath) => ipcRenderer.invoke('load-installed-mods', modsPath),
downloadMod: (modInfo) => ipcRenderer.invoke('download-mod', modInfo),
uninstallMod: (modId, modsPath) => ipcRenderer.invoke('uninstall-mod', modId, modsPath),
toggleMod: (modId, modsPath) => ipcRenderer.invoke('toggle-mod', modId, modsPath),
selectModFiles: () => ipcRenderer.invoke('select-mod-files'),
copyModFile: (sourcePath, modsPath) => ipcRenderer.invoke('copy-mod-file', sourcePath, modsPath),
onProgressUpdate: (callback) => {
ipcRenderer.on('progress-update', (event, data) => callback(data));
},
onProgressComplete: (callback) => {
ipcRenderer.on('progress-complete', () => callback());
},
getUserId: () => ipcRenderer.invoke('get-user-id'),
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
openDownloadPage: () => ipcRenderer.invoke('open-download-page'),
getUpdateInfo: () => ipcRenderer.invoke('get-update-info'),
onUpdatePopup: (callback) => {
ipcRenderer.on('show-update-popup', (event, data) => callback(data));
},
acceptFirstLaunchUpdate: (existingGame) => ipcRenderer.invoke('accept-first-launch-update', existingGame),
markAsLaunched: () => ipcRenderer.invoke('mark-as-launched'),
onFirstLaunchUpdate: (callback) => {
ipcRenderer.on('show-first-launch-update', (event, data) => callback(data));
},
onFirstLaunchWelcome: (callback) => {
ipcRenderer.on('show-first-launch-welcome', () => callback());
},
onFirstLaunchProgress: (callback) => {
ipcRenderer.on('first-launch-progress', (event, data) => callback(data));
},
onLockPlayButton: (callback) => {
ipcRenderer.on('lock-play-button', (event, locked) => callback(locked));
},
getLogDirectory: () => ipcRenderer.invoke('get-log-directory'),
getRecentLogs: (maxLines) => ipcRenderer.invoke('get-recent-logs', maxLines)
});