mirror of
https://git.sanhost.net/sanasol/hytale-f2p.git
synced 2026-02-26 14:51:48 -03:00
Compare commits
97 Commits
v2
...
v2.0.11-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70fe4203ef | ||
|
|
f433120084 | ||
|
|
f4099acbed | ||
|
|
1ba6b22b74 | ||
|
|
a1bc88b754 | ||
|
|
24c2371b50 | ||
|
|
4c6e1a616e | ||
|
|
b54eb4e834 | ||
|
|
a1c74e4175 | ||
|
|
260e6c1126 | ||
|
|
6eb628559b | ||
|
|
052b5dc7dc | ||
|
|
7e9b5046df | ||
|
|
204d6b21f6 | ||
|
|
740d516cfe | ||
|
|
ce052add0d | ||
|
|
d7a904c641 | ||
|
|
d5d2f60c97 | ||
|
|
61433bfeea | ||
|
|
9eb5d1759c | ||
|
|
68d697576a | ||
|
|
a8da559e93 | ||
|
|
75f9403888 | ||
|
|
b61c94d348 | ||
|
|
c0109575d6 | ||
|
|
2a024b61dd | ||
|
|
1c39e8e4c6 | ||
|
|
753bd4fd61 | ||
|
|
cefb4c5575 | ||
|
|
1c779e0e41 | ||
|
|
bb474fe233 | ||
|
|
917f5f455b | ||
|
|
1dd42bdc79 | ||
|
|
7cfe3edd32 | ||
|
|
eb22758ab9 | ||
|
|
42fd51486a | ||
|
|
9ef05e8322 | ||
|
|
4ac12e0e24 | ||
|
|
72a151930e | ||
|
|
a9644b8c64 | ||
|
|
9fc238e103 | ||
|
|
b62e94a415 | ||
|
|
3e82e8fadb | ||
|
|
a355133ccf | ||
|
|
ff5acb5278 | ||
|
|
5d75ca80aa | ||
|
|
281aa6fcde | ||
|
|
158d0af820 | ||
|
|
503f304704 | ||
|
|
234e1e1008 | ||
|
|
d002e831cd | ||
|
|
048f2040f1 | ||
|
|
b05aeef66d | ||
|
|
30265549cf | ||
|
|
5a3efba1d6 | ||
|
|
479f24e86f | ||
|
|
611b7a7084 | ||
|
|
e472435927 | ||
|
|
f40d0105df | ||
|
|
96db9adf68 | ||
|
|
a0f49f126c | ||
|
|
f8333c09cd | ||
|
|
bcf0326763 | ||
|
|
0a5c3db710 | ||
|
|
905a9d754c | ||
|
|
99f032e9ab | ||
|
|
5e3506a9ac | ||
|
|
f2a05d2079 | ||
|
|
cece338609 | ||
|
|
261582a882 | ||
|
|
0f0f0fa308 | ||
|
|
5e6a07f0a6 | ||
|
|
713377fdc6 | ||
|
|
2671a59f38 | ||
|
|
8e9af9c768 | ||
|
|
e962a8880d | ||
|
|
c2d5536dd0 | ||
|
|
727be2ca5c | ||
|
|
64892c81e9 | ||
|
|
fffc730788 | ||
|
|
c475ec7879 | ||
|
|
7efa0d07b0 | ||
|
|
21f8527ed4 | ||
|
|
300616ba82 | ||
|
|
9fdd6f1f44 | ||
|
|
f0b2342c71 | ||
|
|
7dbc900338 | ||
|
|
bc31f58c9c | ||
|
|
7e5a1577a3 | ||
|
|
84f0c0ba71 | ||
|
|
7ede6c2f27 | ||
|
|
be1a24a992 | ||
|
|
9a751958b0 | ||
|
|
9fcf603e08 | ||
|
|
4bc1661587 | ||
|
|
512a53aee7 | ||
|
|
cabb5a57d2 |
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
CURSEFORGE_API_KEY=$1234asdxXXXXXXkQCXXXXXXXXXXASDb32
|
||||
DISCORD_CLIENT_ID=561263XXXXXX
|
||||
18
.github/README1.md
vendored
18
.github/README1.md
vendored
@@ -22,13 +22,25 @@ All builds run in parallel:
|
||||
|
||||
### Creating a Release
|
||||
|
||||
1. Update version in `package.json`
|
||||
**⚠️ IMPORTANT: Semantic Versioning Required**
|
||||
|
||||
This project uses **strict semantic versioning with numerical versions only**:
|
||||
- ✅ **Valid**: `2.0.1`, `2.0.11`, `2.1.0`, `3.0.0`
|
||||
- ❌ **Invalid**: `2.0.2b`, `2.0.2a`, `2.0.1-beta`
|
||||
|
||||
**Format**: `MAJOR.MINOR.PATCH` (e.g., `2.0.11`)
|
||||
|
||||
The auto-update system requires semantic versioning for proper version comparison. Letter suffixes are not supported.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Update version in `package.json` (use numerical format only, e.g., `2.0.11`)
|
||||
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
|
||||
git tag v2.0.11
|
||||
git push origin v2.0.11
|
||||
```
|
||||
|
||||
The workflow will:
|
||||
|
||||
50
.github/workflows/release.yml
vendored
50
.github/workflows/release.yml
vendored
@@ -3,7 +3,7 @@ name: Build and Release
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
@@ -13,19 +13,31 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# FIX Install bsdtar for Pacman builds
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libarchive-tools
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
- run: npm install
|
||||
- run: npm ci
|
||||
- run: npx electron-builder --linux --publish never
|
||||
|
||||
- name: Build Linux Packages
|
||||
run: |
|
||||
npx electron-builder --linux --x64 --arm64 --publish never
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-builds
|
||||
path: |
|
||||
dist/*.AppImage
|
||||
dist/*.AppImage.blockmap
|
||||
dist/*.deb
|
||||
dist/*.rpm
|
||||
dist/*.pacman
|
||||
dist/latest-linux.yml
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
@@ -35,7 +47,6 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
- run: npm install
|
||||
- run: npm ci
|
||||
- run: npx electron-builder --win --publish never
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -43,6 +54,8 @@ jobs:
|
||||
name: windows-builds
|
||||
path: |
|
||||
dist/*.exe
|
||||
dist/*.exe.blockmap
|
||||
dist/latest.yml
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
@@ -52,7 +65,6 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
- run: npm install
|
||||
- run: npm ci
|
||||
- run: npx electron-builder --mac --publish never
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -66,12 +78,19 @@ jobs:
|
||||
release:
|
||||
needs: [build-linux, build-windows, build-macos]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v') ||
|
||||
github.ref == 'refs/heads/release' ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
# FIX: './package.json' Module Not Found in `Get version` step
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -80,13 +99,22 @@ jobs:
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R artifacts
|
||||
|
||||
- name: Get version from package.json
|
||||
id: pkg_version
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# If it's a tag, use the tag.
|
||||
tag_name: ${{ github.ref_type == 'tag' && github.ref_name || format('v{0}.r{1}', steps.pkg_version.outputs.VERSION, github.run_number) }}
|
||||
# If it's the 'release' branch, use 'v2.0.2-beta.r42'
|
||||
name: ${{ github.ref_type == 'tag' && github.ref_name || format('v{0}-beta.r{1}', steps.pkg_version.outputs.VERSION, github.run_number) }}
|
||||
files: |
|
||||
artifacts/linux-builds/*
|
||||
artifacts/windows-builds/*
|
||||
artifacts/macos-builds/*
|
||||
artifacts/linux-builds/**/*
|
||||
artifacts/windows-builds/**/*
|
||||
artifacts/macos-builds/**/*
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
draft: true
|
||||
# DYNAMIC FLAGS: Mark as pre-release ONLY IF it's NOT a tag (meaning it's a branch push)
|
||||
prerelease: ${{ github.ref_type != 'tag' }}
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,3 +1,14 @@
|
||||
dist/*
|
||||
node_modules/*
|
||||
package-lock.json
|
||||
bun.lock
|
||||
|
||||
# Build artifacts
|
||||
src/
|
||||
pkg/
|
||||
|
||||
# Package files
|
||||
*.tar.zst
|
||||
*.zst.DS_Store
|
||||
*.zst
|
||||
bun.lockb
|
||||
.env
|
||||
|
||||
54
BUILD.md
54
BUILD.md
@@ -1,54 +0,0 @@
|
||||
# Build Instructions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### Build for current platform:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Build for specific platform:
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
npm run build:linux
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
npm run build:mac
|
||||
```
|
||||
|
||||
### Build for all platforms:
|
||||
```bash
|
||||
npm run build:all
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Built executables will be in the `dist/` directory:
|
||||
|
||||
- **Windows**: `Hytale F2P Launcher Setup.exe` (NSIS installer) and `Hytale F2P Launcher.exe` (portable)
|
||||
- **Linux**: `Hytale F2P Launcher.AppImage` and `Hytale F2P Launcher.deb`
|
||||
- **macOS**: `Hytale F2P Launcher.dmg` and `Hytale F2P Launcher.zip`
|
||||
|
||||
## Notes
|
||||
|
||||
- Icons need to be placed in `build/` directory:
|
||||
- `icon.ico` for Windows
|
||||
- `icon.png` for Linux
|
||||
- `icon.icns` for macOS
|
||||
- To build for macOS on non-Mac systems, you'll need to run it on a Mac or use a CI/CD service
|
||||
|
||||
593
GUI/index.html
593
GUI/index.html
@@ -1,19 +1,26 @@
|
||||
<!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
|
||||
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" />
|
||||
<img src="https://assets.authbp.xyz/bg.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 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">
|
||||
@@ -26,27 +33,27 @@
|
||||
<div class="sidebar-nav">
|
||||
<div class="nav-item active" data-page="play">
|
||||
<i class="fas fa-play"></i>
|
||||
<span class="nav-tooltip">Play</span>
|
||||
<span class="nav-tooltip" data-i18n="nav.play">Play</span>
|
||||
</div>
|
||||
<div class="nav-item" data-page="mods">
|
||||
<i class="fas fa-box"></i>
|
||||
<span class="nav-tooltip">Mods</span>
|
||||
<span class="nav-tooltip" data-i18n="nav.mods">Mods</span>
|
||||
</div>
|
||||
<div class="nav-item" data-page="news">
|
||||
<i class="fas fa-newspaper"></i>
|
||||
<span class="nav-tooltip">News</span>
|
||||
<span class="nav-tooltip" data-i18n="nav.news">News</span>
|
||||
</div>
|
||||
<div class="nav-item" data-page="chat">
|
||||
<i class="fas fa-comments"></i>
|
||||
<span class="nav-tooltip">Players Chat</span>
|
||||
<span class="nav-tooltip" data-i18n="nav.chat">Players Chat</span>
|
||||
</div>
|
||||
<div class="nav-item" data-page="settings">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span class="nav-tooltip">Settings</span>
|
||||
<span class="nav-tooltip" data-i18n="nav.settings">Settings</span>
|
||||
</div>
|
||||
<div class="nav-item" data-page="skins">
|
||||
<i class="fas fa-user"></i>
|
||||
<span class="nav-tooltip">Skins</span>
|
||||
<div class="nav-item logs-nav-item" data-page="logs" id="openLogsBtn" onclick="openLogs()">
|
||||
<i class="fas fa-terminal"></i>
|
||||
<span class="nav-tooltip">Logs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,14 +64,35 @@
|
||||
<header class="header">
|
||||
<div id="playersOnlineCounter" class="players-counter">
|
||||
<i class="fas fa-users"></i>
|
||||
<span class="counter-label">Players:</span>
|
||||
<span class="counter-label" data-i18n="header.playersLabel">Players:</span>
|
||||
<span id="onlineCount" class="counter-value">0</span>
|
||||
</div>
|
||||
|
||||
<div class="profile-selector" id="profileSelector">
|
||||
<button class="profile-btn" onclick="toggleProfileDropdown()">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
<span id="currentProfileName">Default</span>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="profile-dropdown" id="profileDropdown">
|
||||
<div class="profile-list" id="profileList">
|
||||
<!-- Profiles populated by JS -->
|
||||
</div>
|
||||
<div class="profile-divider"></div>
|
||||
<div class="profile-action" onclick="openProfileManager()">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span data-i18n="header.manageProfiles">Manage Profiles</span>
|
||||
</div>
|
||||
</div>
|
||||
</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 maximize" onclick="toggleMaximize()">
|
||||
<i class="fas fa-square"></i>
|
||||
</button>
|
||||
<button class="control-btn close" onclick="window.electronAPI?.closeWindow()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
@@ -75,9 +103,6 @@
|
||||
<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">
|
||||
@@ -85,28 +110,34 @@
|
||||
<div class="install-content">
|
||||
<div class="install-header">
|
||||
<h1 class="install-title">
|
||||
HYTA<span class="title-accent">LE</span>
|
||||
HY<span class="title-accent">TALE</span>
|
||||
</h1>
|
||||
<p class="install-subtitle">FREE TO PLAY LAUNCHER</p>
|
||||
<p class="install-subtitle" data-i18n="install.title">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" />
|
||||
<label class="form-label" data-i18n="install.playerName">Player Name</label>
|
||||
<input type="text" id="installPlayerName"
|
||||
data-i18n-placeholder="install.playerNamePlaceholder" 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>
|
||||
<span class="checkbox-label" data-i18n="install.customInstallation">Custom
|
||||
Installation</span>
|
||||
</label>
|
||||
|
||||
<div id="installCustomOptions" class="custom-options">
|
||||
<div class="form-subgroup">
|
||||
<label class="form-label">Installation Folder</label>
|
||||
<label class="form-label" data-i18n="install.installationFolder">Installation
|
||||
Folder</label>
|
||||
<div class="input-with-button">
|
||||
<input type="text" id="installPath" placeholder="Default location" class="form-input" readonly />
|
||||
<input type="text" id="installPath"
|
||||
data-i18n-placeholder="install.pathPlaceholder" class="form-input"
|
||||
readonly />
|
||||
<button onclick="browseInstallPath()" class="browse-btn">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
</button>
|
||||
@@ -117,7 +148,7 @@
|
||||
|
||||
<button id="installBtn" class="install-button" onclick="installGame()">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
<span id="installText">INSTALL HYTALE</span>
|
||||
<span id="installText" data-i18n="install.installButton">INSTALL HYTALE</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,14 +161,15 @@
|
||||
<div class="play-header">
|
||||
<h2 class="play-title">
|
||||
<i class="fas fa-play-circle mr-2"></i>
|
||||
READY TO PLAY
|
||||
<span data-i18n="play.ready">READY TO PLAY</span>
|
||||
</h2>
|
||||
<p class="play-subtitle">Launch Hytale and enter the adventure</p>
|
||||
<p class="play-subtitle" data-i18n="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>
|
||||
<span data-i18n="play.playButton">PLAY HYTALE</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,10 +178,11 @@
|
||||
<div class="news-header">
|
||||
<h2 class="news-title">
|
||||
<i class="fas fa-star mr-2"></i>
|
||||
LATEST NEWS
|
||||
<span data-i18n="play.latestNews">LATEST NEWS</span>
|
||||
</h2>
|
||||
<button class="view-all-btn" onclick="navigateToPage('news')">
|
||||
VIEW ALL <i class="fas fa-arrow-right ml-1"></i>
|
||||
<span data-i18n="play.viewAll">VIEW ALL</span> <i
|
||||
class="fas fa-arrow-right ml-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="newsGrid" class="news-grid-horizontal"></div>
|
||||
@@ -160,12 +193,13 @@
|
||||
<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" />
|
||||
<input type="text" id="modsSearch" data-i18n-placeholder="mods.searchPlaceholder"
|
||||
class="mods-search" />
|
||||
</div>
|
||||
<div class="mods-actions">
|
||||
<button id="myModsBtn" class="mods-btn-primary">
|
||||
<i class="fas fa-box"></i>
|
||||
MY MODS
|
||||
<span data-i18n="mods.myMods">MY MODS</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,13 +210,14 @@
|
||||
<div class="mods-pagination">
|
||||
<button id="prevPage" class="pagination-btn">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
PREVIOUS
|
||||
<span data-i18n="mods.previous">PREVIOUS</span>
|
||||
</button>
|
||||
<span class="pagination-info">
|
||||
Page <span id="currentPage">1</span> of <span id="totalPages">1</span>
|
||||
<span data-i18n="mods.page">Page</span> <span id="currentPage">1</span> <span
|
||||
data-i18n="mods.of">of</span> <span id="totalPages">1</span>
|
||||
</span>
|
||||
<button id="nextPage" class="pagination-btn">
|
||||
NEXT
|
||||
<span data-i18n="mods.next">NEXT</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -192,7 +227,7 @@
|
||||
<div class="news-header">
|
||||
<h2 class="news-title">
|
||||
<i class="fas fa-newspaper mr-2"></i>
|
||||
ALL NEWS
|
||||
<span data-i18n="news.title">ALL NEWS</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div id="allNewsGrid" class="news-grid-full"></div>
|
||||
@@ -203,11 +238,17 @@
|
||||
<div class="chat-header">
|
||||
<h2 class="chat-title">
|
||||
<i class="fas fa-comments mr-2"></i>
|
||||
PLAYERS CHAT
|
||||
<span data-i18n="chat.title">PLAYERS CHAT</span>
|
||||
</h2>
|
||||
<div class="chat-header-actions">
|
||||
<button id="chatColorBtn" class="chat-color-btn">
|
||||
<i class="fas fa-palette"></i>
|
||||
<span data-i18n="chat.pickColor">Color</span>
|
||||
</button>
|
||||
<div class="chat-online-badge">
|
||||
<i class="fas fa-circle"></i>
|
||||
<span id="chatOnlineCount">0</span> online
|
||||
<span id="chatOnlineCount">0</span> <span data-i18n="chat.online">online</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,13 +259,9 @@
|
||||
|
||||
<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>
|
||||
<textarea id="chatInput" class="chat-input"
|
||||
data-i18n-placeholder="chat.inputPlaceholder" rows="1"
|
||||
maxlength="500"></textarea>
|
||||
<button id="chatSendBtn" class="chat-send-btn">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
@@ -233,7 +270,7 @@
|
||||
<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 data-i18n="chat.secureChat">Secure chat - Links are censored</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,15 +282,180 @@
|
||||
<div class="settings-header">
|
||||
<h2 class="settings-title">
|
||||
<i class="fas fa-cog mr-2"></i>
|
||||
SETTINGS
|
||||
<span data-i18n="settings.title">SETTINGS</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">
|
||||
<i class="fas fa-gamepad"></i>
|
||||
<span data-i18n="settings.game">Game Options</span>
|
||||
</h3>
|
||||
|
||||
<div class="settings-option">
|
||||
<div class="settings-input-group">
|
||||
<label class="settings-input-label" data-i18n="settings.playerName">Player
|
||||
Name</label>
|
||||
<input type="text" id="settingsPlayerName" class="settings-input"
|
||||
data-i18n-placeholder="settings.playerNamePlaceholder" maxlength="16" />
|
||||
<p class="settings-hint">
|
||||
<i class="fas fa-user"></i>
|
||||
<span data-i18n="settings.playerNameHint">This name will be used in-game
|
||||
(1-16 characters)</span>
|
||||
</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" data-i18n="settings.openGameLocation">Open
|
||||
Game Location</div>
|
||||
<div class="btn-description"
|
||||
data-i18n="settings.openGameLocationDesc">Open the game
|
||||
installation folder</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-option">
|
||||
<div class="settings-button-group">
|
||||
<button id="repairGameBtn" class="settings-action-btn"
|
||||
onclick="repairGame()">
|
||||
<i class="fas fa-tools"></i>
|
||||
<div class="btn-content">
|
||||
<div class="btn-title" data-i18n="settings.repairGame">Repair Game
|
||||
</div>
|
||||
<div class="btn-description" data-i18n="settings.reinstallGame">
|
||||
Reinstall game files (preserves data)
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-input-group">
|
||||
<label class="settings-input-label" data-i18n="settings.gpuPreference">GPU
|
||||
Preference</label>
|
||||
<div class="segmented-control">
|
||||
<input type="radio" id="gpu-auto" name="gpuPreference" value="auto"
|
||||
checked>
|
||||
<label for="gpu-auto" data-i18n="settings.gpuAuto">Auto</label>
|
||||
<input type="radio" id="gpu-integrated" name="gpuPreference"
|
||||
value="integrated">
|
||||
<label for="gpu-integrated"
|
||||
data-i18n="settings.gpuIntegrated">Integrated</label>
|
||||
<input type="radio" id="gpu-dedicated" name="gpuPreference"
|
||||
value="dedicated">
|
||||
<label for="gpu-dedicated"
|
||||
data-i18n="settings.gpuDedicated">Dedicated</label>
|
||||
</div>
|
||||
<p class="settings-hint">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span data-i18n="settings.gpuHint">Select your preferred GPU (Linux:
|
||||
affects DRI_PRIME)</span>
|
||||
</p>
|
||||
<div id="gpu-detection-info" class="gpu-detection-info"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">
|
||||
<i class="fas fa-fingerprint"></i>
|
||||
<span data-i18n="settings.account">Player UUID Management</span>
|
||||
</h3>
|
||||
|
||||
<div class="settings-option">
|
||||
<div class="settings-input-group">
|
||||
<label class="settings-input-label" data-i18n="settings.currentUUID">Current
|
||||
UUID</label>
|
||||
<div class="uuid-display-container">
|
||||
<input type="text" id="currentUuid" class="settings-input uuid-input"
|
||||
readonly data-i18n-placeholder="settings.uuidPlaceholder" />
|
||||
<button id="copyUuidBtn" class="uuid-btn copy-btn" title="Copy UUID">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<button id="regenerateUuidBtn" class="uuid-btn regenerate-btn"
|
||||
title="Generate New UUID">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="settings-hint">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span data-i18n="settings.uuidHint">Your unique player identifier for
|
||||
this username</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-option">
|
||||
<div class="settings-button-group">
|
||||
<button id="manageUuidsBtn" class="settings-action-btn">
|
||||
<i class="fas fa-list"></i>
|
||||
<div class="btn-content">
|
||||
<div class="btn-title" data-i18n="settings.manageUUIDs">Manage All
|
||||
UUIDs</div>
|
||||
<div class="btn-description" data-i18n="settings.manageUUIDsDesc">
|
||||
View and manage all player UUIDs</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">
|
||||
<i class="fab fa-discord"></i>
|
||||
<span data-i18n="settings.discord">Discord Integration</span>
|
||||
</h3>
|
||||
|
||||
<div class="settings-option">
|
||||
<label class="settings-checkbox">
|
||||
<input type="checkbox" id="discordRPCCheck" checked />
|
||||
<span class="checkmark"></span>
|
||||
<div class="checkbox-content">
|
||||
<div class="checkbox-title" data-i18n="settings.enableRPC">Enable
|
||||
Discord Rich Presence</div>
|
||||
<div class="checkbox-description"
|
||||
data-i18n="settings.discordDescription">Show your launcher activity
|
||||
on Discord
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">
|
||||
<i class="fas fa-window-close"></i>
|
||||
<span data-i18n="settings.closeLauncher">Launcher Behavior</span>
|
||||
</h3>
|
||||
|
||||
<div class="settings-option">
|
||||
<label class="settings-checkbox">
|
||||
<input type="checkbox" id="closeLauncherCheck" />
|
||||
<span class="checkmark"></span>
|
||||
<div class="checkbox-content">
|
||||
<div class="checkbox-title" data-i18n="settings.closeOnStart">Close Launcher on game start</div>
|
||||
<div class="checkbox-description" data-i18n="settings.closeOnStartDescription">
|
||||
Automatically close the launcher after Hytale has launched
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">
|
||||
<i class="fas fa-coffee"></i>
|
||||
Java Runtime
|
||||
<span data-i18n="settings.java">Java Runtime</span>
|
||||
</h3>
|
||||
|
||||
<div class="settings-option">
|
||||
@@ -261,31 +463,31 @@
|
||||
<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 class="checkbox-title" data-i18n="settings.useCustomJava">Use
|
||||
Custom Java Path</div>
|
||||
<div class="checkbox-description" data-i18n="settings.javaDescription">
|
||||
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>
|
||||
<label class="settings-input-label" data-i18n="settings.javaPath">Java
|
||||
Executable Path</label>
|
||||
<div class="settings-input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
id="customJavaPath"
|
||||
class="settings-input"
|
||||
placeholder="Select Java path..."
|
||||
readonly
|
||||
/>
|
||||
<input type="text" id="customJavaPath" class="settings-input"
|
||||
data-i18n-placeholder="settings.javaPathPlaceholder" readonly />
|
||||
<button id="browseJavaBtn" class="settings-browse-btn">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
Browse
|
||||
<span data-i18n="settings.javaBrowse">Browse</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="settings-hint">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Select the Java installation folder (supports Windows, Mac, Linux)
|
||||
<span data-i18n="settings.javaHint">Select the Java installation folder
|
||||
(supports Windows, Mac, Linux)</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,24 +495,17 @@
|
||||
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">
|
||||
<i class="fas fa-gamepad"></i>
|
||||
Game Options
|
||||
<i class="fas fa-language"></i>
|
||||
<span data-i18n="settings.language">Language</span>
|
||||
</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>
|
||||
<label class="settings-input-label"
|
||||
data-i18n="settings.selectLanguage">Select Language</label>
|
||||
<select id="languageSelect" class="settings-input">
|
||||
<!-- Options populated by i18n.js -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -318,11 +513,30 @@
|
||||
</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 id="logs-page" class="page">
|
||||
<div class="logs-container">
|
||||
<div class="logs-header">
|
||||
<h2 class="logs-title">
|
||||
<i class="fas fa-terminal"></i>
|
||||
<span data-i18n="settings.logs">SYSTEM LOGS</span>
|
||||
</h2>
|
||||
<div class="logs-actions">
|
||||
<button class="logs-action-btn" onclick="copyLogs()">
|
||||
<i class="fas fa-copy"></i> <span data-i18n="settings.logsCopy">Copy</span>
|
||||
</button>
|
||||
<button class="logs-action-btn" onclick="refreshLogs()">
|
||||
<i class="fas fa-sync-alt"></i> <span
|
||||
data-i18n="settings.logsRefresh">Refresh</span>
|
||||
</button>
|
||||
<button class="logs-action-btn" onclick="openLogsFolder()">
|
||||
<i class="fas fa-folder-open"></i> <span data-i18n="settings.logsFolder">Open
|
||||
Folder</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logsTerminal" class="logs-terminal">
|
||||
<div class="text-gray-500 text-center mt-10" data-i18n="settings.logsLoading">Loading
|
||||
logs...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,7 +549,7 @@
|
||||
<div class="mods-modal-header">
|
||||
<h2 class="mods-modal-title">
|
||||
<i class="fas fa-box mr-2"></i>
|
||||
MY MODS
|
||||
<span data-i18n="mods.modalTitle">MY MODS</span>
|
||||
</h2>
|
||||
<button id="closeMyModsModal" class="mods-modal-close">
|
||||
<i class="fas fa-times"></i>
|
||||
@@ -351,7 +565,7 @@
|
||||
<div id="progressOverlay" class="progress-overlay" style="display: none;">
|
||||
<div class="progress-content">
|
||||
<div class="progress-info">
|
||||
<span id="progressText">Initializing...</span>
|
||||
<span id="progressText" data-i18n="progress.initializing">Initializing...</span>
|
||||
<span id="progressPercent">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar-container">
|
||||
@@ -364,60 +578,239 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installation effects overlay -->
|
||||
<div id="installationEffects" class="installation-effects" style="display: none;">
|
||||
<div class="space-effects">
|
||||
<div class="warp-line"></div>
|
||||
<div class="warp-line"></div>
|
||||
<div class="warp-line"></div>
|
||||
<div class="warp-line"></div>
|
||||
<div class="warp-line"></div>
|
||||
<div class="warp-line"></div>
|
||||
<div class="warp-line"></div>
|
||||
<div class="warp-line"></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
|
||||
<span data-i18n="chat.joinChat">Join Chat</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="chat-username-modal-body">
|
||||
<p class="chat-username-modal-description">
|
||||
<p class="chat-username-modal-description" data-i18n="chat.chooseUsername">
|
||||
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>
|
||||
<label for="chatUsernameInput" class="chat-username-label"
|
||||
data-i18n="chat.username">Username</label>
|
||||
<input type="text" id="chatUsernameInput" class="chat-username-input"
|
||||
data-i18n-placeholder="chat.usernamePlaceholder" maxlength="20" autocomplete="off" />
|
||||
<span class="chat-username-hint" data-i18n="chat.usernameHint">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
|
||||
<span data-i18n="common.cancel">Cancel</span>
|
||||
</button>
|
||||
<button id="chatUsernameSubmit" class="chat-username-btn-submit">
|
||||
<i class="fas fa-check"></i>
|
||||
Join Chat
|
||||
<span data-i18n="chat.joinButton">Join Chat</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- UUID Management Modal -->
|
||||
<div id="uuidModal" class="uuid-modal" style="display: none;">
|
||||
<div class="uuid-modal-content">
|
||||
<div class="uuid-modal-header">
|
||||
<h2 class="uuid-modal-title">
|
||||
<i class="fas fa-fingerprint mr-2"></i>
|
||||
<span data-i18n="uuid.modalTitle">UUID Management</span>
|
||||
</h2>
|
||||
<button id="uuidModalClose" class="modal-close-btn">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="uuid-modal-body">
|
||||
<div class="uuid-current-section">
|
||||
<h3 class="uuid-section-title" data-i18n="uuid.currentUserUUID">Current User UUID</h3>
|
||||
<div class="uuid-current-display">
|
||||
<input type="text" id="modalCurrentUuid" class="uuid-display-input" readonly />
|
||||
<button id="modalCopyUuidBtn" class="uuid-action-btn copy-btn" title="Copy UUID">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<button id="modalRegenerateUuidBtn" class="uuid-action-btn regenerate-btn"
|
||||
title="Generate New UUID">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uuid-list-section">
|
||||
<div class="uuid-list-header">
|
||||
<h3 class="uuid-section-title" data-i18n="uuid.allPlayerUUIDs">All Player UUIDs</h3>
|
||||
<button id="generateNewUuidBtn" class="uuid-generate-btn">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span data-i18n="uuid.generateNew">Generate New UUID</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="uuidList" class="uuid-list">
|
||||
<div class="uuid-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span data-i18n="uuid.loadingUUIDs">Loading UUIDs...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uuid-custom-section">
|
||||
<h3 class="uuid-section-title" data-i18n="uuid.setCustomUUID">Set Custom UUID</h3>
|
||||
<div class="uuid-custom-form">
|
||||
<input type="text" id="customUuidInput" class="uuid-input"
|
||||
data-i18n-placeholder="uuid.customPlaceholder" maxlength="36" />
|
||||
<button id="setCustomUuidBtn" class="uuid-set-btn">
|
||||
<i class="fas fa-check"></i>
|
||||
<span data-i18n="uuid.setUUID">Set UUID</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="uuid-custom-hint">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span data-i18n="uuid.warning">Warning: Setting a custom UUID will change your current player
|
||||
identity</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Manager Modal -->
|
||||
<div id="profileManagerModal" class="profile-modal" style="display: none;">
|
||||
<div class="profile-modal-content">
|
||||
<div class="profile-modal-header">
|
||||
<h2 class="profile-modal-title">
|
||||
<i class="fas fa-users-cog mr-2"></i>
|
||||
<span data-i18n="profiles.modalTitle">Manage Profiles</span>
|
||||
</h2>
|
||||
<button class="modal-close-btn" onclick="closeProfileManager()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="profile-modal-body">
|
||||
<div class="profile-manager-list" id="managerProfileList">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div class="profile-create-section">
|
||||
<input type="text" id="newProfileName" data-i18n-placeholder="profiles.newProfilePlaceholder"
|
||||
class="profile-input" maxlength="20">
|
||||
<button class="profile-create-btn" onclick="createNewProfile()">
|
||||
<i class="fas fa-plus"></i> <span data-i18n="profiles.createProfile">Create Profile</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="version-display-bottom">
|
||||
<i class="fas fa-code-branch"></i>
|
||||
<span id="launcherVersion">Loading...</span>
|
||||
</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>Made by <a href="https://github.com/amiayweb" target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors">@amiayweb</a> & <a
|
||||
href="https://github.com/Relyz1993" target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors">@Relyz</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>
|
||||
<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>,
|
||||
<a href="https://github.com/fazrigading" target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors">@fazrigading</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="js/script.js"></script>
|
||||
<script type="module" src="js/script.js"></script> <!-- Discord Notification -->
|
||||
<div id="discordNotification" class="discord-notification">
|
||||
<div class="notification-content">
|
||||
<i class="fab fa-discord"></i>
|
||||
<span class="notification-text" data-i18n="discord.notificationText">Join our Discord community!</span>
|
||||
<button class="notification-action"
|
||||
onclick="window.electronAPI?.openExternal('https://discord.gg/n6HZ7NwSQd')">
|
||||
<span data-i18n="discord.joinButton">Join Discord</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="notification-close" onclick="closeDiscordNotification()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal pour sélectionner la couleur du chat -->
|
||||
<div id="chatColorModal" class="chat-color-modal" style="display: none;">
|
||||
<div class="chat-color-modal-content">
|
||||
<div class="chat-color-modal-header">
|
||||
<h3 class="chat-color-modal-title">
|
||||
<i class="fas fa-palette"></i>
|
||||
<span data-i18n="chat.colorModal.title">Customize Username Color</span>
|
||||
</h3>
|
||||
<button class="modal-close-btn" onclick="closeChatColorModal()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-color-modal-body">
|
||||
<div id="solidColorSection" class="color-section">
|
||||
<h4 data-i18n="chat.colorModal.chooseSolid">Choose a solid color:</h4>
|
||||
<div class="predefined-colors">
|
||||
<div class="color-option" data-color="#3498db" style="background: #3498db;"></div>
|
||||
<div class="color-option" data-color="#e74c3c" style="background: #e74c3c;"></div>
|
||||
<div class="color-option" data-color="#2ecc71" style="background: #2ecc71;"></div>
|
||||
<div class="color-option" data-color="#f39c12" style="background: #f39c12;"></div>
|
||||
<div class="color-option" data-color="#9b59b6" style="background: #9b59b6;"></div>
|
||||
<div class="color-option" data-color="#1abc9c" style="background: #1abc9c;"></div>
|
||||
<div class="color-option" data-color="#e91e63" style="background: #e91e63;"></div>
|
||||
<div class="color-option" data-color="#ff5722" style="background: #ff5722;"></div>
|
||||
</div>
|
||||
<div class="custom-color-input">
|
||||
<label for="customColor" data-i18n="chat.colorModal.customColor">Custom color:</label>
|
||||
<input type="color" id="customColor" value="#3498db">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-preview">
|
||||
<h4 data-i18n="chat.colorModal.preview">Preview:</h4>
|
||||
<div id="colorPreview" class="preview-username" data-i18n="chat.colorModal.previewUsername">
|
||||
YourUsername</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-color-modal-footer">
|
||||
<button class="btn-secondary" onclick="closeChatColorModal()"><span
|
||||
data-i18n="common.cancel">Cancel</span></button>
|
||||
<button class="btn-primary" onclick="applyChatColor()"><span data-i18n="chat.colorModal.apply">Apply
|
||||
Color</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/i18n.js"></script>
|
||||
<script type="module" src="js/update.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
</html>
|
||||
154
GUI/js/chat.js
154
GUI/js/chat.js
@@ -3,20 +3,34 @@ let socket = null;
|
||||
let isAuthenticated = false;
|
||||
let messageQueue = [];
|
||||
let chatUsername = '';
|
||||
const SOCKET_URL = 'http://3.10.208.30:3001';
|
||||
let userColor = '#3498db';
|
||||
let userBadge = null;
|
||||
const SOCKET_URL = 'https://chat.hytalef2p.com';
|
||||
const MAX_MESSAGE_LENGTH = 500;
|
||||
|
||||
async function getOrCreatePlayerId() {
|
||||
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
export async function initChat() {
|
||||
if (window.electronAPI?.loadChatUsername) {
|
||||
chatUsername = await window.electronAPI.loadChatUsername();
|
||||
}
|
||||
|
||||
if (window.electronAPI?.loadChatColor) {
|
||||
const savedColor = await window.electronAPI.loadChatColor();
|
||||
if (savedColor) {
|
||||
userColor = savedColor;
|
||||
}
|
||||
}
|
||||
|
||||
if (!chatUsername || chatUsername.trim() === '') {
|
||||
showUsernameModal();
|
||||
return;
|
||||
}
|
||||
|
||||
setupChatUI();
|
||||
setupColorSelector();
|
||||
await connectToChat();
|
||||
}
|
||||
|
||||
@@ -136,13 +150,22 @@ async function connectToChat() {
|
||||
reconnectionDelay: 1000
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.on('connect', async () => {
|
||||
console.log('Connected to chat server');
|
||||
socket.emit('authenticate', { username: chatUsername, userId });
|
||||
|
||||
const uuid = await window.electronAPI?.getCurrentUuid();
|
||||
|
||||
socket.emit('authenticate', {
|
||||
username: chatUsername,
|
||||
userId,
|
||||
uuid: uuid,
|
||||
userColor: userColor
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('authenticated', (data) => {
|
||||
isAuthenticated = true;
|
||||
userBadge = data.badge;
|
||||
addSystemMessage(`Connected as ${data.username}`);
|
||||
|
||||
while (messageQueue.length > 0) {
|
||||
@@ -155,7 +178,7 @@ async function connectToChat() {
|
||||
if (data.type === 'system') {
|
||||
addSystemMessage(data.message);
|
||||
} else if (data.type === 'user') {
|
||||
addUserMessage(data.username, data.message, data.timestamp);
|
||||
addUserMessage(data.username, data.message, data.timestamp, data.userColor, data.badge);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -226,7 +249,7 @@ function sendMessage() {
|
||||
updateCharCounter();
|
||||
}
|
||||
|
||||
function addUserMessage(username, message, timestamp) {
|
||||
function addUserMessage(username, message, timestamp, userColor = '#3498db', badge = null) {
|
||||
const chatMessages = document.getElementById('chatMessages');
|
||||
if (!chatMessages) return;
|
||||
|
||||
@@ -238,14 +261,35 @@ function addUserMessage(username, message, timestamp) {
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
let badgeHTML = '';
|
||||
if (badge) {
|
||||
let badgeStyle = '';
|
||||
if (badge.style === 'rainbow') {
|
||||
badgeStyle = `background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4, #ffeaa7, #fab1a0, #fd79a8); background-size: 400% 400%; animation: rainbow 3s ease infinite; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; font-weight: bold; display: inline;`;
|
||||
} else if (badge.style === 'gradient') {
|
||||
if (badge.badge === 'CONTRIBUTOR') {
|
||||
badgeStyle = `background: linear-gradient(45deg, #22c55e, #16a34a); background-size: 200% 200%; animation: contributorGlow 2s ease infinite; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; font-weight: bold; display: inline;`;
|
||||
} else {
|
||||
badgeStyle = `color: ${badge.color}; font-weight: bold; display: inline;`;
|
||||
}
|
||||
}
|
||||
|
||||
badgeHTML = `<span class="user-badge" style="${badgeStyle}">[${badge.badge}]</span> `;
|
||||
}
|
||||
|
||||
messageDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<span class="message-username">${escapeHtml(username)}</span>
|
||||
<span class="message-user-info">${badgeHTML}<span class="message-username" style="font-weight: bold;" data-username-color="${userColor}">${escapeHtml(username)}</span></span>
|
||||
<span class="message-time">${time}</span>
|
||||
</div>
|
||||
<div class="message-content">${message}</div>
|
||||
`;
|
||||
|
||||
const usernameElement = messageDiv.querySelector('.message-username');
|
||||
if (usernameElement) {
|
||||
applyUserColorStyle(usernameElement, userColor);
|
||||
}
|
||||
|
||||
chatMessages.appendChild(messageDiv);
|
||||
scrollToBottom();
|
||||
}
|
||||
@@ -352,6 +396,104 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
function setupColorSelector() {
|
||||
const colorBtn = document.getElementById('chatColorBtn');
|
||||
if (colorBtn) {
|
||||
colorBtn.addEventListener('click', showChatColorModal);
|
||||
}
|
||||
|
||||
const colorOptions = document.querySelectorAll('.color-option');
|
||||
colorOptions.forEach(option => {
|
||||
option.addEventListener('click', () => {
|
||||
document.querySelectorAll('.color-option').forEach(o => o.classList.remove('selected'));
|
||||
option.classList.add('selected');
|
||||
updateColorPreview();
|
||||
});
|
||||
});
|
||||
|
||||
const customColor = document.getElementById('customColor');
|
||||
if (customColor) {
|
||||
customColor.addEventListener('input', () => {
|
||||
document.querySelectorAll('.color-option').forEach(o => o.classList.remove('selected'));
|
||||
updateColorPreview();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showChatColorModal() {
|
||||
const modal = document.getElementById('chatColorModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
updateColorPreview();
|
||||
}
|
||||
}
|
||||
|
||||
window.closeChatColorModal = function() {
|
||||
const modal = document.getElementById('chatColorModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateColorPreview() {
|
||||
const preview = document.getElementById('colorPreview');
|
||||
if (!preview) return;
|
||||
|
||||
const selectedOption = document.querySelector('.color-option.selected');
|
||||
let color = '#3498db';
|
||||
|
||||
if (selectedOption) {
|
||||
color = selectedOption.dataset.color;
|
||||
} else {
|
||||
const customColor = document.getElementById('customColor');
|
||||
if (customColor) color = customColor.value;
|
||||
}
|
||||
|
||||
preview.style.color = color;
|
||||
preview.style.background = 'transparent';
|
||||
preview.style.webkitBackgroundClip = 'initial';
|
||||
preview.style.webkitTextFillColor = 'initial';
|
||||
}
|
||||
|
||||
window.applyChatColor = async function() {
|
||||
let newColor;
|
||||
|
||||
const selectedOption = document.querySelector('.color-option.selected');
|
||||
if (selectedOption) {
|
||||
newColor = selectedOption.dataset.color;
|
||||
} else {
|
||||
const customColor = document.getElementById('customColor');
|
||||
newColor = customColor ? customColor.value : '#3498db';
|
||||
}
|
||||
|
||||
userColor = newColor;
|
||||
|
||||
if (window.electronAPI?.saveChatColor) {
|
||||
await window.electronAPI.saveChatColor(newColor);
|
||||
}
|
||||
|
||||
if (socket && isAuthenticated) {
|
||||
const uuid = await window.electronAPI?.getCurrentUuid();
|
||||
socket.emit('authenticate', {
|
||||
username: chatUsername,
|
||||
userId: await getOrCreatePlayerId(),
|
||||
uuid: uuid,
|
||||
userColor: userColor
|
||||
});
|
||||
|
||||
addSystemMessage('Username color updated successfully', 'success');
|
||||
}
|
||||
|
||||
closeChatColorModal();
|
||||
}
|
||||
|
||||
function applyUserColorStyle(element, color) {
|
||||
element.style.color = color;
|
||||
element.style.background = 'transparent';
|
||||
element.style.webkitBackgroundClip = 'initial';
|
||||
element.style.webkitTextFillColor = 'initial';
|
||||
}
|
||||
|
||||
window.ChatAPI = {
|
||||
send: sendMessage,
|
||||
disconnect: () => socket?.disconnect()
|
||||
|
||||
89
GUI/js/i18n.js
Normal file
89
GUI/js/i18n.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// Minimal i18n system - optimized async loading
|
||||
const i18n = (() => {
|
||||
let currentLang = 'en';
|
||||
let translations = {};
|
||||
const availableLanguages = [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'es', name: 'Español' },
|
||||
{ code: 'pt-BR', name: 'Português (Brasil)' }
|
||||
];
|
||||
|
||||
// Load single language file
|
||||
async function loadLanguage(lang) {
|
||||
if (translations[lang]) return true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`locales/${lang}.json`);
|
||||
if (response.ok) {
|
||||
translations[lang] = await response.json();
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load language: ${lang}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get translation by key
|
||||
function t(key) {
|
||||
const keys = key.split('.');
|
||||
let value = translations[currentLang];
|
||||
|
||||
for (const k of keys) {
|
||||
if (value && value[k] !== undefined) {
|
||||
value = value[k];
|
||||
} else {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Set language
|
||||
async function setLanguage(lang) {
|
||||
await loadLanguage(lang);
|
||||
if (translations[lang]) {
|
||||
currentLang = lang;
|
||||
updateDOM();
|
||||
window.electronAPI?.saveLanguage(lang);
|
||||
}
|
||||
}
|
||||
|
||||
// Update all elements with data-i18n attribute
|
||||
function updateDOM() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = t(key);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.title = t(key);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize - load saved language only
|
||||
async function init(savedLang) {
|
||||
const lang = savedLang || 'en';
|
||||
await loadLanguage(lang);
|
||||
currentLang = lang;
|
||||
updateDOM();
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
t,
|
||||
setLanguage,
|
||||
getAvailableLanguages: () => availableLanguages,
|
||||
getCurrentLanguage: () => currentLang
|
||||
};
|
||||
})();
|
||||
|
||||
// Make i18n globally available
|
||||
window.i18n = i18n;
|
||||
@@ -30,6 +30,28 @@ export function setupInstallation() {
|
||||
if (installPlayerName) {
|
||||
installPlayerName.addEventListener('change', savePlayerName);
|
||||
}
|
||||
|
||||
if (window.electronAPI && window.electronAPI.onProgressUpdate) {
|
||||
window.electronAPI.onProgressUpdate((data) => {
|
||||
if (!isDownloading) return;
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Setup installation effects listeners
|
||||
if (window.electronAPI && window.electronAPI.onInstallationStart) {
|
||||
window.electronAPI.onInstallationStart(() => {
|
||||
showInstallationEffects();
|
||||
});
|
||||
}
|
||||
|
||||
if (window.electronAPI && window.electronAPI.onInstallationEnd) {
|
||||
window.electronAPI.onInstallationEnd(() => {
|
||||
hideInstallationEffects();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function installGame() {
|
||||
@@ -42,7 +64,7 @@ export async function installGame() {
|
||||
isDownloading = true;
|
||||
if (installBtn) {
|
||||
installBtn.disabled = true;
|
||||
installText.textContent = 'INSTALLING...';
|
||||
installText.textContent = window.i18n ? window.i18n.t('install.installing') : 'INSTALLING...';
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -50,13 +72,15 @@ export async function installGame() {
|
||||
const result = await window.electronAPI.installGame(playerName, '', installPath);
|
||||
|
||||
if (result.success) {
|
||||
const successMsg = window.i18n ? window.i18n.t('progress.installationComplete') : 'Installation completed successfully!';
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress({ message: 'Installation completed successfully!' });
|
||||
window.LauncherUI.updateProgress({ message: successMsg });
|
||||
setTimeout(() => {
|
||||
window.LauncherUI.hideProgress();
|
||||
window.LauncherUI.showLauncherOrInstall(true);
|
||||
const playerNameInput = document.getElementById('playerName');
|
||||
if (playerNameInput) playerNameInput.value = playerName;
|
||||
resetInstallButton();
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
@@ -66,12 +90,20 @@ export async function installGame() {
|
||||
simulateInstallation(playerName);
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress({ message: `Installation failed: ${error.message}` });
|
||||
setTimeout(() => {
|
||||
window.LauncherUI.hideProgress();
|
||||
const errorMsg = window.i18n ? window.i18n.t('progress.installationFailed').replace('{error}', error.message) : `Installation failed: ${error.message}`;
|
||||
|
||||
// Hide installation effects on error
|
||||
if (window.hideInstallationEffects) {
|
||||
window.hideInstallationEffects();
|
||||
}
|
||||
|
||||
// Reset button state on error
|
||||
resetInstallButton();
|
||||
}, 3000);
|
||||
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress({ message: errorMsg });
|
||||
// Don't hide progress bar, just update the message
|
||||
// User can see the error and close it manually
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,10 +114,13 @@ function simulateInstallation(playerName) {
|
||||
progress += Math.random() * 3;
|
||||
if (progress > 100) progress = 100;
|
||||
|
||||
const installingMsg = window.i18n ? window.i18n.t('progress.installingGameFiles') : 'Installing game files...';
|
||||
const completeMsg = window.i18n ? window.i18n.t('progress.installComplete') : 'Installation complete!';
|
||||
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress({
|
||||
percent: progress,
|
||||
message: progress < 100 ? 'Installing game files...' : 'Installation complete!',
|
||||
message: progress < 100 ? installingMsg : completeMsg,
|
||||
speed: 1024 * 1024 * (5 + Math.random() * 10),
|
||||
downloaded: progress * 1024 * 1024 * 20,
|
||||
total: 1024 * 1024 * 2000
|
||||
@@ -94,9 +129,10 @@ function simulateInstallation(playerName) {
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval);
|
||||
const successMsg = window.i18n ? window.i18n.t('progress.installationComplete') : 'Installation completed successfully!';
|
||||
setTimeout(() => {
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress({ message: 'Installation completed successfully!' });
|
||||
window.LauncherUI.updateProgress({ message: successMsg });
|
||||
setTimeout(() => {
|
||||
window.LauncherUI.hideProgress();
|
||||
window.LauncherUI.showLauncherOrInstall(true);
|
||||
@@ -192,3 +228,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
setupInstallation();
|
||||
await checkGameStatusAndShowInterface();
|
||||
});
|
||||
window.browseInstallPath = browseInstallPath;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
setupInstallation();
|
||||
await checkGameStatusAndShowInterface();
|
||||
});
|
||||
|
||||
@@ -25,14 +25,172 @@ export function setupLauncher() {
|
||||
|
||||
if (window.electronAPI && window.electronAPI.onProgressUpdate) {
|
||||
window.electronAPI.onProgressUpdate((data) => {
|
||||
if (!isDownloading) return;
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.showProgress();
|
||||
window.LauncherUI.updateProgress(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial Profile Load
|
||||
loadProfiles();
|
||||
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
const selector = document.getElementById('profileSelector');
|
||||
if (selector && !selector.contains(e.target)) {
|
||||
const dropdown = document.getElementById('profileDropdown');
|
||||
if (dropdown) dropdown.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PROFILE MANAGEMENT
|
||||
// ==========================================
|
||||
|
||||
async function loadProfiles() {
|
||||
try {
|
||||
if (!window.electronAPI || !window.electronAPI.profile) return;
|
||||
|
||||
const profiles = await window.electronAPI.profile.list();
|
||||
const activeProfile = await window.electronAPI.profile.getActive();
|
||||
|
||||
renderProfileList(profiles, activeProfile);
|
||||
updateCurrentProfileUI(activeProfile);
|
||||
} catch (error) {
|
||||
console.error('Failed to load profiles:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderProfileList(profiles, activeProfile) {
|
||||
const list = document.getElementById('profileList');
|
||||
const managerList = document.getElementById('managerProfileList');
|
||||
|
||||
if (!list) return;
|
||||
|
||||
// Dropdown List
|
||||
list.innerHTML = profiles.map(p => `
|
||||
<div class="profile-item ${p.id === activeProfile.id ? 'active' : ''}"
|
||||
onclick="switchProfile('${p.id}')">
|
||||
<span>${p.name}</span>
|
||||
${p.id === activeProfile.id ? '<i class="fas fa-check ml-auto"></i>' : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Manager Modal List
|
||||
if (managerList) {
|
||||
managerList.innerHTML = profiles.map(p => `
|
||||
<div class="profile-manager-item ${p.id === activeProfile.id ? 'active' : ''}">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-user-circle text-xl text-gray-400"></i>
|
||||
<div>
|
||||
<div class="font-bold">${p.name}</div>
|
||||
<div class="text-xs text-gray-500">ID: ${p.id.substring(0, 8)}...</div>
|
||||
</div>
|
||||
</div>
|
||||
${p.id !== activeProfile.id ? `
|
||||
<button class="profile-delete-btn" onclick="deleteProfile('${p.id}')" title="Delete Profile">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
` : '<span class="text-xs text-green-500 font-bold px-2">ACTIVE</span>'}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function updateCurrentProfileUI(profile) {
|
||||
const nameEl = document.getElementById('currentProfileName');
|
||||
if (nameEl && profile) {
|
||||
nameEl.textContent = profile.name;
|
||||
}
|
||||
}
|
||||
|
||||
window.toggleProfileDropdown = () => {
|
||||
const dropdown = document.getElementById('profileDropdown');
|
||||
if (dropdown) {
|
||||
dropdown.classList.toggle('show');
|
||||
}
|
||||
};
|
||||
|
||||
window.openProfileManager = () => {
|
||||
const modal = document.getElementById('profileManagerModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
// Refresh list
|
||||
loadProfiles();
|
||||
}
|
||||
// Close dropdown
|
||||
const dropdown = document.getElementById('profileDropdown');
|
||||
if (dropdown) dropdown.classList.remove('show');
|
||||
};
|
||||
|
||||
window.closeProfileManager = () => {
|
||||
const modal = document.getElementById('profileManagerModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
};
|
||||
|
||||
window.createNewProfile = async () => {
|
||||
const input = document.getElementById('newProfileName');
|
||||
if (!input || !input.value.trim()) return;
|
||||
|
||||
try {
|
||||
const name = input.value.trim();
|
||||
await window.electronAPI.profile.create(name);
|
||||
input.value = '';
|
||||
await loadProfiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to create profile:', error);
|
||||
alert('Failed to create profile: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
window.deleteProfile = async (id) => {
|
||||
if (!confirm('Are you sure you want to delete this profile? parameters and mods configuration will be lost.')) return;
|
||||
|
||||
try {
|
||||
await window.electronAPI.profile.delete(id);
|
||||
await loadProfiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete profile:', error);
|
||||
alert('Failed to delete profile: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
window.switchProfile = async (id) => {
|
||||
try {
|
||||
if (window.LauncherUI) window.LauncherUI.showProgress();
|
||||
const switchingMsg = window.i18n ? window.i18n.t('progress.switchingProfile') : 'Switching Profile...';
|
||||
if (window.LauncherUI) window.LauncherUI.updateProgress({ message: switchingMsg });
|
||||
|
||||
await window.electronAPI.profile.activate(id);
|
||||
|
||||
// Refresh UI
|
||||
await loadProfiles();
|
||||
|
||||
// Refresh Mods
|
||||
if (window.modsManager) {
|
||||
if (window.modsManager.loadInstalledMods) await window.modsManager.loadInstalledMods();
|
||||
if (window.modsManager.loadBrowseMods) await window.modsManager.loadBrowseMods();
|
||||
}
|
||||
|
||||
// Close dropdown
|
||||
const dropdown = document.getElementById('profileDropdown');
|
||||
if (dropdown) dropdown.classList.remove('show');
|
||||
|
||||
if (window.LauncherUI) {
|
||||
const switchedMsg = window.i18n ? window.i18n.t('progress.profileSwitched') : 'Profile Switched!';
|
||||
window.LauncherUI.updateProgress({ message: switchedMsg });
|
||||
setTimeout(() => window.LauncherUI.hideProgress(), 1000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to switch profile:', error);
|
||||
alert('Failed to switch profile: ' + error.message);
|
||||
if (window.LauncherUI) window.LauncherUI.hideProgress();
|
||||
}
|
||||
};
|
||||
|
||||
export async function launch() {
|
||||
if (isDownloading || (playBtn && playBtn.disabled)) return;
|
||||
|
||||
@@ -48,6 +206,15 @@ export async function launch() {
|
||||
javaPath = window.SettingsAPI.getCurrentJavaPath();
|
||||
}
|
||||
|
||||
let gpuPreference = 'auto';
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.loadGpuPreference) {
|
||||
gpuPreference = await window.electronAPI.loadGpuPreference();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading GPU preference:', error);
|
||||
}
|
||||
|
||||
if (window.LauncherUI) window.LauncherUI.showProgress();
|
||||
isDownloading = true;
|
||||
if (playBtn) {
|
||||
@@ -56,51 +223,196 @@ export async function launch() {
|
||||
}
|
||||
|
||||
try {
|
||||
const startingMsg = window.i18n ? window.i18n.t('progress.startingGame') : 'Starting game...';
|
||||
if (window.LauncherUI) window.LauncherUI.updateProgress({ message: startingMsg });
|
||||
|
||||
if (window.electronAPI && window.electronAPI.launchGame) {
|
||||
const result = await window.electronAPI.launchGame(playerName, javaPath, '');
|
||||
const result = await window.electronAPI.launchGame(playerName, javaPath, '', gpuPreference);
|
||||
|
||||
isDownloading = false;
|
||||
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.hideProgress();
|
||||
}
|
||||
resetPlayButton();
|
||||
|
||||
if (result.success) {
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress({ message: 'Game started successfully!' });
|
||||
setTimeout(() => {
|
||||
window.LauncherUI.hideProgress();
|
||||
if (window.electronAPI.minimizeWindow) {
|
||||
setTimeout(() => {
|
||||
window.electronAPI.minimizeWindow();
|
||||
}
|
||||
}, 2000);
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Launch failed');
|
||||
console.error('Launch failed:', result.error);
|
||||
}
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
isDownloading = false;
|
||||
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress({ message: 'Game started successfully!' });
|
||||
setTimeout(() => {
|
||||
window.LauncherUI.hideProgress();
|
||||
resetPlayButton();
|
||||
}, 2000);
|
||||
}
|
||||
}, 2000);
|
||||
resetPlayButton();
|
||||
}
|
||||
} catch (error) {
|
||||
isDownloading = false;
|
||||
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress({ message: `Failed: ${error.message}` });
|
||||
setTimeout(() => {
|
||||
window.LauncherUI.hideProgress();
|
||||
resetPlayButton();
|
||||
}, 3000);
|
||||
}
|
||||
resetPlayButton();
|
||||
console.error('Launch error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uninstallGame() {
|
||||
if (!confirm('Are you sure you want to uninstall Hytale? All game files will be deleted.')) {
|
||||
return;
|
||||
function showCustomConfirm(message, title, onConfirm, onCancel = null, confirmText, cancelText) {
|
||||
// Apply defaults with i18n support
|
||||
title = title || (window.i18n ? window.i18n.t('confirm.defaultTitle') : 'Confirm Action');
|
||||
confirmText = confirmText || (window.i18n ? window.i18n.t('common.confirm') : 'Confirm');
|
||||
cancelText = cancelText || (window.i18n ? window.i18n.t('common.cancel') : 'Cancel');
|
||||
|
||||
const existingModal = document.querySelector('.custom-confirm-modal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'custom-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 = 'custom-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;">${title}</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="custom-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;
|
||||
">${cancelText}</button>
|
||||
<button class="custom-confirm-action" style="
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
">${confirmText}</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('.custom-confirm-cancel');
|
||||
const actionBtn = dialog.querySelector('.custom-confirm-action');
|
||||
|
||||
const closeModal = () => {
|
||||
modal.style.opacity = '0';
|
||||
dialog.style.transform = 'scale(0.9)';
|
||||
setTimeout(() => {
|
||||
modal.remove();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
cancelBtn.onclick = () => {
|
||||
closeModal();
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
|
||||
actionBtn.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);
|
||||
}
|
||||
|
||||
export async function uninstallGame() {
|
||||
const message = window.i18n ? window.i18n.t('confirm.uninstallGameMessage') : 'Are you sure you want to uninstall Hytale? All game files will be deleted.';
|
||||
const title = window.i18n ? window.i18n.t('confirm.uninstallGameTitle') : 'Uninstall Game';
|
||||
const confirmBtn = window.i18n ? window.i18n.t('confirm.uninstallGameButton') : 'Uninstall';
|
||||
const cancelBtn = window.i18n ? window.i18n.t('common.cancel') : 'Cancel';
|
||||
|
||||
showCustomConfirm(
|
||||
message,
|
||||
title,
|
||||
async () => {
|
||||
await performUninstall();
|
||||
},
|
||||
null,
|
||||
confirmBtn,
|
||||
cancelBtn
|
||||
);
|
||||
}
|
||||
|
||||
async function performUninstall() {
|
||||
|
||||
if (window.LauncherUI) window.LauncherUI.showProgress();
|
||||
if (window.LauncherUI) window.LauncherUI.updateProgress({ message: 'Uninstalling game...' });
|
||||
const uninstallingMsg = window.i18n ? window.i18n.t('progress.uninstallingGame') : 'Uninstalling game...';
|
||||
if (window.LauncherUI) window.LauncherUI.updateProgress({ message: uninstallingMsg });
|
||||
if (uninstallBtn) uninstallBtn.disabled = true;
|
||||
|
||||
try {
|
||||
@@ -108,8 +420,9 @@ export async function uninstallGame() {
|
||||
const result = await window.electronAPI.uninstallGame();
|
||||
|
||||
if (result.success) {
|
||||
const successMsg = window.i18n ? window.i18n.t('progress.gameUninstalled') : 'Game uninstalled successfully!';
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress({ message: 'Game uninstalled successfully!' });
|
||||
window.LauncherUI.updateProgress({ message: successMsg });
|
||||
setTimeout(() => {
|
||||
window.LauncherUI.hideProgress();
|
||||
window.LauncherUI.showLauncherOrInstall(false);
|
||||
@@ -119,9 +432,10 @@ export async function uninstallGame() {
|
||||
throw new Error(result.error || 'Uninstall failed');
|
||||
}
|
||||
} else {
|
||||
const successMsg = window.i18n ? window.i18n.t('progress.gameUninstalled') : 'Game uninstalled successfully!';
|
||||
setTimeout(() => {
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress({ message: 'Game uninstalled successfully!' });
|
||||
window.LauncherUI.updateProgress({ message: successMsg });
|
||||
setTimeout(() => {
|
||||
window.LauncherUI.hideProgress();
|
||||
window.LauncherUI.showLauncherOrInstall(false);
|
||||
@@ -130,8 +444,9 @@ export async function uninstallGame() {
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = window.i18n ? window.i18n.t('progress.uninstallFailed').replace('{error}', error.message) : `Uninstall failed: ${error.message}`;
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress({ message: `Uninstall failed: ${error.message}` });
|
||||
window.LauncherUI.updateProgress({ message: errorMsg });
|
||||
setTimeout(() => window.LauncherUI.hideProgress(), 3000);
|
||||
}
|
||||
} finally {
|
||||
@@ -139,11 +454,54 @@ export async function uninstallGame() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function repairGame() {
|
||||
showCustomConfirm(
|
||||
'Are you sure you want to repair Hytale? This will reinstall the game files but keep your data (saves, screenshots, etc.).',
|
||||
'Repair Game',
|
||||
async () => {
|
||||
await performRepair();
|
||||
},
|
||||
null,
|
||||
'Repair',
|
||||
'Cancel'
|
||||
);
|
||||
}
|
||||
|
||||
async function performRepair() {
|
||||
if (window.LauncherUI) window.LauncherUI.showProgress();
|
||||
if (window.LauncherUI) window.LauncherUI.updateProgress({ message: 'Repairing game...' });
|
||||
isDownloading = true;
|
||||
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.repairGame) {
|
||||
const result = await window.electronAPI.repairGame();
|
||||
|
||||
if (result.success) {
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress({ message: 'Game repaired successfully!' });
|
||||
setTimeout(() => {
|
||||
window.LauncherUI.hideProgress();
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Repair failed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.updateProgress({ message: `Repair failed: ${error.message}` });
|
||||
setTimeout(() => window.LauncherUI.hideProgress(), 3000);
|
||||
}
|
||||
} finally {
|
||||
isDownloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetPlayButton() {
|
||||
isDownloading = false;
|
||||
if (playBtn) {
|
||||
playBtn.disabled = false;
|
||||
playText.textContent = 'PLAY';
|
||||
playText.textContent = window.i18n ? window.i18n.t('play.play') : 'PLAY';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,5 +589,59 @@ async function loadCustomJavaPath() {
|
||||
|
||||
window.launch = launch;
|
||||
window.uninstallGame = uninstallGame;
|
||||
window.repairGame = repairGame;
|
||||
|
||||
window.openLogs = async () => {
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.showPage('logs-page');
|
||||
window.LauncherUI.setActiveNav('logs');
|
||||
}
|
||||
await refreshLogs();
|
||||
};
|
||||
|
||||
window.openLogsFolder = async () => {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.openLogsFolder) {
|
||||
await window.electronAPI.openLogsFolder();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open logs folder:', error);
|
||||
}
|
||||
};
|
||||
|
||||
window.refreshLogs = async () => {
|
||||
const terminal = document.getElementById('logsTerminal');
|
||||
if (!terminal) return;
|
||||
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.getRecentLogs) {
|
||||
// Fetch up to MAX_LOG_LINES lines
|
||||
const logs = await window.electronAPI.getRecentLogs(MAX_LOG_LINES);
|
||||
if (logs) {
|
||||
// Formatting for colors could be done here if needed
|
||||
terminal.textContent = logs;
|
||||
terminal.scrollTop = terminal.scrollHeight;
|
||||
} else {
|
||||
terminal.textContent = 'No logs available.';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
terminal.textContent = 'Error loading logs: ' + error.message;
|
||||
}
|
||||
};
|
||||
|
||||
window.copyLogs = () => {
|
||||
const terminal = document.getElementById('logsTerminal');
|
||||
if (terminal) {
|
||||
navigator.clipboard.writeText(terminal.textContent)
|
||||
.then(() => alert('Logs copied to clipboard!'))
|
||||
.catch(err => console.error('Failed to copy logs:', err));
|
||||
}
|
||||
};
|
||||
|
||||
window.repairGame = repairGame;
|
||||
|
||||
// Constants
|
||||
const MAX_LOG_LINES = 500;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', setupLauncher);
|
||||
|
||||
96
GUI/js/logs.js
Normal file
96
GUI/js/logs.js
Normal file
@@ -0,0 +1,96 @@
|
||||
|
||||
// Logs Page Logic
|
||||
|
||||
async function loadLogs() {
|
||||
const terminal = document.getElementById('logsTerminal');
|
||||
if (!terminal) return;
|
||||
|
||||
terminal.innerHTML = '<div class="text-gray-500 text-center mt-10">Loading logs...</div>';
|
||||
|
||||
try {
|
||||
const logs = await window.electronAPI.getRecentLogs(500); // Fetch last 500 lines
|
||||
|
||||
if (logs) {
|
||||
// Escape HTML to prevent XSS and preserve format
|
||||
const safeLogs = logs.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
terminal.innerHTML = `<pre class="logs-content">${safeLogs}</pre>`;
|
||||
|
||||
// Auto scroll to bottom
|
||||
terminal.scrollTop = terminal.scrollHeight;
|
||||
} else {
|
||||
terminal.innerHTML = '<div class="text-gray-500 text-center mt-10">No logs found.</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load logs:', error);
|
||||
terminal.innerHTML = `<div class="text-red-500 text-center mt-10">Error loading logs: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
const btn = document.querySelector('button[onclick="refreshLogs()"] i');
|
||||
if (btn) btn.classList.add('fa-spin');
|
||||
|
||||
await loadLogs();
|
||||
|
||||
if (btn) setTimeout(() => btn.classList.remove('fa-spin'), 500);
|
||||
}
|
||||
|
||||
async function copyLogs() {
|
||||
const terminal = document.getElementById('logsTerminal');
|
||||
if (!terminal) return;
|
||||
|
||||
const content = terminal.innerText;
|
||||
if (!content) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
|
||||
const btn = document.querySelector('button[onclick="copyLogs()"]');
|
||||
const originalText = btn.innerHTML;
|
||||
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalText;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy logs:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function openLogsFolder() {
|
||||
await window.electronAPI.openLogsFolder();
|
||||
}
|
||||
|
||||
function openLogs() {
|
||||
// Navigation is handled by sidebar logic, but we can trigger a refresh
|
||||
window.LauncherUI.showPage('logs-page');
|
||||
window.LauncherUI.setActiveNav('logs');
|
||||
refreshLogs();
|
||||
}
|
||||
|
||||
// Expose functions globally
|
||||
window.refreshLogs = refreshLogs;
|
||||
window.copyLogs = copyLogs;
|
||||
window.openLogsFolder = openLogsFolder;
|
||||
window.openLogs = openLogs;
|
||||
|
||||
// Auto-load logs when the page becomes active
|
||||
const logsObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.target.classList.contains('active') && mutation.target.id === 'logs-page') {
|
||||
loadLogs();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const logsPage = document.getElementById('logs-page');
|
||||
if (logsPage) {
|
||||
logsObserver.observe(logsPage, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
});
|
||||
112
GUI/js/mods.js
112
GUI/js/mods.js
@@ -1,5 +1,5 @@
|
||||
|
||||
const API_KEY = '$2a$10$bqk254NMZOWVTzLVJCcxEOmhcyUujKxA5xk.kQCN9q0KNYFJd5b32';
|
||||
let API_KEY = null;
|
||||
const CURSEFORGE_API = 'https://api.curseforge.com/v1';
|
||||
const HYTALE_GAME_ID = 70216;
|
||||
|
||||
@@ -11,6 +11,15 @@ let modsPageSize = 20;
|
||||
let modsTotalPages = 1;
|
||||
|
||||
export async function initModsManager() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.getEnvVar) {
|
||||
API_KEY = await window.electronAPI.getEnvVar('CURSEFORGE_API_KEY');
|
||||
console.log('Loaded API Key:', API_KEY ? 'Yes' : 'No');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load API Key:', err);
|
||||
}
|
||||
|
||||
setupModsEventListeners();
|
||||
await loadInstalledMods();
|
||||
await loadBrowseMods();
|
||||
@@ -113,10 +122,15 @@ function displayInstalledMods(mods) {
|
||||
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>
|
||||
<h4 data-i18n="mods.noModsInstalled">No Mods Installed</h4>
|
||||
<p data-i18n="mods.noModsInstalledDesc">Add mods from CurseForge or import local files</p>
|
||||
</div>
|
||||
`;
|
||||
if (window.i18n) {
|
||||
const container = modsContainer.querySelector('.empty-installed-mods');
|
||||
container.querySelector('h4').textContent = window.i18n.t('mods.noModsInstalled');
|
||||
container.querySelector('p').textContent = window.i18n.t('mods.noModsInstalledDesc');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,9 +152,9 @@ function displayInstalledMods(mods) {
|
||||
|
||||
function createInstalledModCard(mod) {
|
||||
const statusClass = mod.enabled ? 'text-primary' : 'text-zinc-500';
|
||||
const statusText = mod.enabled ? 'ACTIVE' : 'DISABLED';
|
||||
const statusText = mod.enabled ? (window.i18n ? window.i18n.t('mods.active') : 'ACTIVE') : (window.i18n ? window.i18n.t('mods.disabled') : 'DISABLED');
|
||||
const toggleBtnClass = mod.enabled ? 'btn-disable' : 'btn-enable';
|
||||
const toggleBtnText = mod.enabled ? 'DISABLE' : 'ENABLE';
|
||||
const toggleBtnText = mod.enabled ? (window.i18n ? window.i18n.t('mods.disable') : 'DISABLE') : (window.i18n ? window.i18n.t('mods.enable') : 'ENABLE');
|
||||
const toggleIcon = mod.enabled ? 'fa-pause' : 'fa-play';
|
||||
|
||||
return `
|
||||
@@ -154,7 +168,7 @@ function createInstalledModCard(mod) {
|
||||
<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>
|
||||
<p class="installed-mod-description">${mod.description || (window.i18n ? window.i18n.t('mods.noDescription') : 'No description available')}</p>
|
||||
</div>
|
||||
|
||||
<div class="installed-mod-actions">
|
||||
@@ -163,7 +177,7 @@ function createInstalledModCard(mod) {
|
||||
${statusText}
|
||||
</div>
|
||||
<div class="installed-mod-buttons">
|
||||
<button id="delete-installed-${mod.id}" class="installed-mod-btn-icon" title="Delete mod">
|
||||
<button id="delete-installed-${mod.id}" class="installed-mod-btn-icon" title="${window.i18n ? window.i18n.t('mods.delete') : 'Delete mod'}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<button id="toggle-installed-${mod.id}" class="installed-mod-btn-toggle ${toggleBtnClass}">
|
||||
@@ -180,7 +194,7 @@ 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>';
|
||||
browseContainer.innerHTML = `<div class="loading-mods"><div class="loading-spinner"></div><span>${window.i18n ? window.i18n.t('mods.loadingMods') : 'Loading mods from CurseForge...'}</span></div>`;
|
||||
|
||||
try {
|
||||
if (!API_KEY || API_KEY.length < 10) {
|
||||
@@ -264,10 +278,15 @@ function displayBrowseMods(mods) {
|
||||
browseContainer.innerHTML = `
|
||||
<div class=\"empty-browse-mods\">
|
||||
<i class=\"fas fa-search\"></i>
|
||||
<h4>No Mods Found</h4>
|
||||
<p>Try adjusting your search</p>
|
||||
<h4 data-i18n="mods.noModsFound">No Mods Found</h4>
|
||||
<p data-i18n="mods.noModsFoundDesc">Try adjusting your search</p>
|
||||
</div>
|
||||
`;
|
||||
if (window.i18n) {
|
||||
const container = browseContainer.querySelector('.empty-browse-mods');
|
||||
container.querySelector('h4').textContent = window.i18n.t('mods.noModsFound');
|
||||
container.querySelector('p').textContent = window.i18n.t('mods.noModsFoundDesc');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -282,10 +301,17 @@ function displayBrowseMods(mods) {
|
||||
}
|
||||
|
||||
function createBrowseModCard(mod) {
|
||||
const isInstalled = installedMods.some(installed =>
|
||||
installed.name.toLowerCase().includes(mod.name.toLowerCase()) ||
|
||||
installed.curseForgeId == mod.id
|
||||
);
|
||||
const isInstalled = installedMods.some(installed => {
|
||||
// Check by CurseForge ID (most reliable)
|
||||
if (installed.curseForgeId && installed.curseForgeId.toString() === mod.id.toString()) {
|
||||
return true;
|
||||
}
|
||||
// Check by exact name match for manually installed mods
|
||||
if (installed.name.toLowerCase() === mod.name.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return `
|
||||
<div class=\"mod-card ${isInstalled ? 'installed' : ''}\" data-mod-id=\"${mod.id}\">
|
||||
@@ -317,16 +343,16 @@ function createBrowseModCard(mod) {
|
||||
<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
|
||||
${window.i18n ? window.i18n.t('mods.view') : '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 id="install-${mod.id}" class="mod-btn-toggle bg-primary text-black hover:bg-primary/80">
|
||||
<i class="fas fa-download"></i>
|
||||
${window.i18n ? window.i18n.t('mods.install') : 'INSTALL'}
|
||||
</button>` :
|
||||
`<button class=\"mod-btn-toggle bg-white/10 text-white\" disabled>
|
||||
<i class=\"fas fa-check\"></i>
|
||||
INSTALLED
|
||||
`<button class="mod-btn-toggle bg-white/10 text-white" disabled>
|
||||
<i class="fas fa-check"></i>
|
||||
${window.i18n ? window.i18n.t('mods.installed') : 'INSTALLED'}
|
||||
</button>`
|
||||
}
|
||||
</div>
|
||||
@@ -336,7 +362,8 @@ function createBrowseModCard(mod) {
|
||||
|
||||
async function downloadAndInstallMod(modInfo) {
|
||||
try {
|
||||
window.LauncherUI?.showProgress(`Downloading ${modInfo.name}...`);
|
||||
const downloadMsg = window.i18n ? window.i18n.t('notifications.modsDownloading').replace('{name}', modInfo.name) : `Downloading ${modInfo.name}...`;
|
||||
window.LauncherUI?.showProgress(downloadMsg);
|
||||
|
||||
const result = await window.electronAPI?.downloadMod(modInfo);
|
||||
|
||||
@@ -360,20 +387,23 @@ async function downloadAndInstallMod(modInfo) {
|
||||
await loadInstalledMods();
|
||||
await loadBrowseMods();
|
||||
window.LauncherUI?.hideProgress();
|
||||
showNotification(`${modInfo.name} installed successfully! 🎉`, 'success');
|
||||
const successMsg = window.i18n ? window.i18n.t('notifications.modsInstalledSuccess').replace('{name}', modInfo.name) : `${modInfo.name} installed successfully! 🎉`;
|
||||
showNotification(successMsg, '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');
|
||||
const errorMsg = window.i18n ? window.i18n.t('notifications.modsDownloadFailed').replace('{error}', error.message) : 'Failed to download mod: ' + error.message;
|
||||
showNotification(errorMsg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMod(modId) {
|
||||
try {
|
||||
window.LauncherUI?.showProgress('Toggling mod...');
|
||||
const toggleMsg = window.i18n ? window.i18n.t('notifications.modsTogglingMod') : 'Toggling mod...';
|
||||
window.LauncherUI?.showProgress(toggleMsg);
|
||||
|
||||
const modsPath = await window.electronAPI?.getModsPath();
|
||||
const result = await window.electronAPI?.toggleMod(modId, modsPath);
|
||||
@@ -387,7 +417,8 @@ async function toggleMod(modId) {
|
||||
} catch (error) {
|
||||
console.error('Error toggling mod:', error);
|
||||
window.LauncherUI?.hideProgress();
|
||||
showNotification('Failed to toggle mod: ' + error.message, 'error');
|
||||
const errorMsg = window.i18n ? window.i18n.t('notifications.modsToggleFailed').replace('{error}', error.message) : 'Failed to toggle mod: ' + error.message;
|
||||
showNotification(errorMsg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,11 +426,16 @@ async function deleteMod(modId) {
|
||||
const mod = installedMods.find(m => m.id === modId);
|
||||
if (!mod) return;
|
||||
|
||||
const confirmMsg = window.i18n ?
|
||||
window.i18n.t('mods.confirmDelete').replace('{name}', mod.name) + ' ' + window.i18n.t('mods.confirmDeleteDesc') :
|
||||
`Are you sure you want to delete "${mod.name}"? This action cannot be undone.`;
|
||||
|
||||
showConfirmModal(
|
||||
`Are you sure you want to delete "${mod.name}"? This action cannot be undone.`,
|
||||
confirmMsg,
|
||||
async () => {
|
||||
try {
|
||||
window.LauncherUI?.showProgress('Deleting mod...');
|
||||
const deleteMsg = window.i18n ? window.i18n.t('notifications.modsDeletingMod') : 'Deleting mod...';
|
||||
window.LauncherUI?.showProgress(deleteMsg);
|
||||
|
||||
const modsPath = await window.electronAPI?.getModsPath();
|
||||
const result = await window.electronAPI?.uninstallMod(modId, modsPath);
|
||||
@@ -408,14 +444,16 @@ async function deleteMod(modId) {
|
||||
await loadInstalledMods();
|
||||
await loadBrowseMods();
|
||||
window.LauncherUI?.hideProgress();
|
||||
showNotification(`"${mod.name}" deleted successfully`, 'success');
|
||||
const successMsg = window.i18n ? window.i18n.t('notifications.modsDeletedSuccess').replace('{name}', mod.name) : `"${mod.name}" deleted successfully`;
|
||||
showNotification(successMsg, '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');
|
||||
const errorMsg = window.i18n ? window.i18n.t('notifications.modsDeleteFailed').replace('{error}', error.message) : 'Failed to delete mod: ' + error.message;
|
||||
showNotification(errorMsg, 'error');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -522,7 +560,6 @@ function showNotification(message, type = 'info', duration = 4000) {
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// Custom confirmation modal
|
||||
function showConfirmModal(message, onConfirm, onCancel = null) {
|
||||
const existingModal = document.querySelector('.mod-confirm-modal');
|
||||
if (existingModal) {
|
||||
@@ -565,7 +602,7 @@ function showConfirmModal(message, onConfirm, onCancel = null) {
|
||||
<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>
|
||||
<h3 style="margin: 0; font-size: 1.2rem; font-weight: 600;">${window.i18n ? window.i18n.t('mods.confirmDeletion') : 'Confirm Deletion'}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 24px; color: #e5e7eb;">
|
||||
@@ -581,7 +618,7 @@ function showConfirmModal(message, onConfirm, onCancel = null) {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
">Cancel</button>
|
||||
">${window.i18n ? window.i18n.t('common.cancel') : 'Cancel'}</button>
|
||||
<button class="mod-confirm-delete" style="
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
@@ -591,7 +628,7 @@ function showConfirmModal(message, onConfirm, onCancel = null) {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
">Delete</button>
|
||||
">${window.i18n ? window.i18n.t('common.delete') : 'Delete'}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -709,7 +746,8 @@ function viewModPage(modId) {
|
||||
}
|
||||
} else {
|
||||
console.error('Mod not found with ID:', modId);
|
||||
showNotification('Mod information not found', 'error');
|
||||
const errorMsg = window.i18n ? window.i18n.t('notifications.modsModNotFound') : 'Mod information not found';
|
||||
showNotification(errorMsg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,7 +756,9 @@ window.modsManager = {
|
||||
deleteMod,
|
||||
openMyModsModal,
|
||||
closeMyModsModal,
|
||||
viewModPage
|
||||
viewModPage,
|
||||
loadInstalledMods,
|
||||
loadBrowseMods
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initModsManager);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
const API_URL = 'http://3.10.208.30/api';
|
||||
const API_URL = 'https://api.hytalef2p.com/api';
|
||||
let updateInterval = null;
|
||||
let currentUserId = null;
|
||||
|
||||
|
||||
@@ -6,4 +6,77 @@ import './mods.js';
|
||||
import './players.js';
|
||||
import './chat.js';
|
||||
import './settings.js';
|
||||
import './logs.js';
|
||||
|
||||
// Initialize i18n immediately (before DOMContentLoaded)
|
||||
let i18nInitialized = false;
|
||||
(async () => {
|
||||
const savedLang = await window.electronAPI?.loadLanguage();
|
||||
await i18n.init(savedLang);
|
||||
i18nInitialized = true;
|
||||
|
||||
// Update language selector if DOM is already loaded
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
updateLanguageSelector();
|
||||
}
|
||||
})();
|
||||
|
||||
function updateLanguageSelector() {
|
||||
const langSelect = document.getElementById('languageSelect');
|
||||
if (langSelect) {
|
||||
// Clear existing options
|
||||
langSelect.innerHTML = '';
|
||||
|
||||
const languages = i18n.getAvailableLanguages();
|
||||
const currentLang = i18n.getCurrentLanguage();
|
||||
|
||||
languages.forEach(lang => {
|
||||
const option = document.createElement('option');
|
||||
option.value = lang.code;
|
||||
option.textContent = lang.name;
|
||||
if (lang.code === currentLang) {
|
||||
option.selected = true;
|
||||
}
|
||||
langSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Handle language change (add listener only once)
|
||||
if (!langSelect.hasAttribute('data-listener-added')) {
|
||||
langSelect.addEventListener('change', async (e) => {
|
||||
await i18n.setLanguage(e.target.value);
|
||||
});
|
||||
langSelect.setAttribute('data-listener-added', 'true');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Populate language selector (wait for i18n if needed)
|
||||
if (i18nInitialized) {
|
||||
updateLanguageSelector();
|
||||
}
|
||||
|
||||
// Discord notification
|
||||
const notification = document.getElementById('discordNotification');
|
||||
if (notification) {
|
||||
const dismissed = localStorage.getItem('discordNotificationDismissed');
|
||||
if (!dismissed) {
|
||||
setTimeout(() => {
|
||||
notification.style.display = 'flex';
|
||||
}, 3000);
|
||||
} else {
|
||||
notification.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.closeDiscordNotification = function() {
|
||||
const notification = document.getElementById('discordNotification');
|
||||
if (notification) {
|
||||
notification.classList.add('hidden');
|
||||
setTimeout(() => {
|
||||
notification.style.display = 'none';
|
||||
}, 300);
|
||||
}
|
||||
localStorage.setItem('discordNotificationDismissed', 'true');
|
||||
};
|
||||
@@ -4,6 +4,152 @@ let customJavaOptions;
|
||||
let customJavaPath;
|
||||
let browseJavaBtn;
|
||||
let settingsPlayerName;
|
||||
let discordRPCCheck;
|
||||
let closeLauncherCheck;
|
||||
let gpuPreferenceRadios;
|
||||
|
||||
|
||||
// UUID Management elements
|
||||
let currentUuidDisplay;
|
||||
let copyUuidBtn;
|
||||
let regenerateUuidBtn;
|
||||
let manageUuidsBtn;
|
||||
let uuidModal;
|
||||
let uuidModalClose;
|
||||
let modalCurrentUuid;
|
||||
let modalCopyUuidBtn;
|
||||
let modalRegenerateUuidBtn;
|
||||
let generateNewUuidBtn;
|
||||
let uuidList;
|
||||
let customUuidInput;
|
||||
let setCustomUuidBtn;
|
||||
|
||||
function showCustomConfirm(message, title, onConfirm, onCancel = null, confirmText, cancelText) {
|
||||
// Apply defaults with i18n support
|
||||
title = title || (window.i18n ? window.i18n.t('confirm.defaultTitle') : 'Confirm Action');
|
||||
confirmText = confirmText || (window.i18n ? window.i18n.t('common.confirm') : 'Confirm');
|
||||
cancelText = cancelText || (window.i18n ? window.i18n.t('common.cancel') : 'Cancel');
|
||||
|
||||
const existingModal = document.querySelector('.custom-confirm-modal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'custom-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 = 'custom-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(147, 51, 234, 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: #9333ea;">
|
||||
<i class="fas fa-exclamation-triangle" style="font-size: 24px;"></i>
|
||||
<h3 style="margin: 0; font-size: 1.2rem; font-weight: 600;">${title}</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="custom-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;
|
||||
">${cancelText}</button>
|
||||
<button class="custom-confirm-action" style="
|
||||
background: linear-gradient(135deg, #9333ea, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
">${confirmText}</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('.custom-confirm-cancel');
|
||||
const actionBtn = dialog.querySelector('.custom-confirm-action');
|
||||
|
||||
const closeModal = () => {
|
||||
modal.style.opacity = '0';
|
||||
dialog.style.transform = 'scale(0.9)';
|
||||
setTimeout(() => {
|
||||
modal.remove();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
cancelBtn.onclick = () => {
|
||||
closeModal();
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
|
||||
actionBtn.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);
|
||||
}
|
||||
|
||||
|
||||
export function initSettings() {
|
||||
setupSettingsElements();
|
||||
@@ -16,6 +162,25 @@ function setupSettingsElements() {
|
||||
customJavaPath = document.getElementById('customJavaPath');
|
||||
browseJavaBtn = document.getElementById('browseJavaBtn');
|
||||
settingsPlayerName = document.getElementById('settingsPlayerName');
|
||||
discordRPCCheck = document.getElementById('discordRPCCheck');
|
||||
closeLauncherCheck = document.getElementById('closeLauncherCheck');
|
||||
gpuPreferenceRadios = document.querySelectorAll('input[name="gpuPreference"]');
|
||||
|
||||
|
||||
// UUID Management elements
|
||||
currentUuidDisplay = document.getElementById('currentUuid');
|
||||
copyUuidBtn = document.getElementById('copyUuidBtn');
|
||||
regenerateUuidBtn = document.getElementById('regenerateUuidBtn');
|
||||
manageUuidsBtn = document.getElementById('manageUuidsBtn');
|
||||
uuidModal = document.getElementById('uuidModal');
|
||||
uuidModalClose = document.getElementById('uuidModalClose');
|
||||
modalCurrentUuid = document.getElementById('modalCurrentUuid');
|
||||
modalCopyUuidBtn = document.getElementById('modalCopyUuidBtn');
|
||||
modalRegenerateUuidBtn = document.getElementById('modalRegenerateUuidBtn');
|
||||
generateNewUuidBtn = document.getElementById('generateNewUuidBtn');
|
||||
uuidList = document.getElementById('uuidList');
|
||||
customUuidInput = document.getElementById('customUuidInput');
|
||||
setCustomUuidBtn = document.getElementById('setCustomUuidBtn');
|
||||
|
||||
if (customJavaCheck) {
|
||||
customJavaCheck.addEventListener('change', toggleCustomJava);
|
||||
@@ -28,6 +193,65 @@ function setupSettingsElements() {
|
||||
if (settingsPlayerName) {
|
||||
settingsPlayerName.addEventListener('change', savePlayerName);
|
||||
}
|
||||
|
||||
if (discordRPCCheck) {
|
||||
discordRPCCheck.addEventListener('change', saveDiscordRPC);
|
||||
}
|
||||
|
||||
if (closeLauncherCheck) {
|
||||
closeLauncherCheck.addEventListener('change', saveCloseLauncher);
|
||||
}
|
||||
|
||||
|
||||
// UUID event listeners
|
||||
if (copyUuidBtn) {
|
||||
copyUuidBtn.addEventListener('click', copyCurrentUuid);
|
||||
}
|
||||
|
||||
if (regenerateUuidBtn) {
|
||||
regenerateUuidBtn.addEventListener('click', regenerateCurrentUuid);
|
||||
}
|
||||
|
||||
if (manageUuidsBtn) {
|
||||
manageUuidsBtn.addEventListener('click', openUuidModal);
|
||||
}
|
||||
|
||||
if (uuidModalClose) {
|
||||
uuidModalClose.addEventListener('click', closeUuidModal);
|
||||
}
|
||||
|
||||
if (modalCopyUuidBtn) {
|
||||
modalCopyUuidBtn.addEventListener('click', copyCurrentUuid);
|
||||
}
|
||||
|
||||
if (modalRegenerateUuidBtn) {
|
||||
modalRegenerateUuidBtn.addEventListener('click', regenerateCurrentUuid);
|
||||
}
|
||||
|
||||
if (generateNewUuidBtn) {
|
||||
generateNewUuidBtn.addEventListener('click', generateNewUuid);
|
||||
}
|
||||
|
||||
if (setCustomUuidBtn) {
|
||||
setCustomUuidBtn.addEventListener('click', setCustomUuid);
|
||||
}
|
||||
|
||||
if (uuidModal) {
|
||||
uuidModal.addEventListener('click', (e) => {
|
||||
if (e.target === uuidModal) {
|
||||
closeUuidModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (gpuPreferenceRadios) {
|
||||
gpuPreferenceRadios.forEach(radio => {
|
||||
radio.addEventListener('change', async () => {
|
||||
await saveGpuPreference();
|
||||
await updateGpuLabel();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCustomJava() {
|
||||
@@ -90,36 +314,203 @@ async function loadCustomJavaPath() {
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlayerName() {
|
||||
async function saveDiscordRPC() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.saveUsername && settingsPlayerName) {
|
||||
const playerName = settingsPlayerName.value.trim() || 'Player';
|
||||
await window.electronAPI.saveUsername(playerName);
|
||||
if (window.electronAPI && window.electronAPI.saveDiscordRPC && discordRPCCheck) {
|
||||
const enabled = discordRPCCheck.checked;
|
||||
console.log('Saving Discord RPC setting:', enabled);
|
||||
|
||||
const result = await window.electronAPI.saveDiscordRPC(enabled);
|
||||
|
||||
if (result && result.success) {
|
||||
console.log('Discord RPC setting saved successfully:', enabled);
|
||||
|
||||
// Feedback visuel pour l'utilisateur
|
||||
if (enabled) {
|
||||
const msg = window.i18n ? window.i18n.t('notifications.discordEnabled') : 'Discord Rich Presence enabled';
|
||||
showNotification(msg, 'success');
|
||||
} else {
|
||||
const msg = window.i18n ? window.i18n.t('notifications.discordDisabled') : 'Discord Rich Presence disabled';
|
||||
showNotification(msg, 'success');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to save Discord RPC setting');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving Discord RPC setting:', error);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.discordSaveFailed') : 'Failed to save Discord setting';
|
||||
showNotification(msg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiscordRPC() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.loadDiscordRPC) {
|
||||
const enabled = await window.electronAPI.loadDiscordRPC();
|
||||
if (discordRPCCheck) {
|
||||
discordRPCCheck.checked = enabled;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading Discord RPC setting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCloseLauncher() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.saveCloseLauncher && closeLauncherCheck) {
|
||||
const enabled = closeLauncherCheck.checked;
|
||||
await window.electronAPI.saveCloseLauncher(enabled);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving close launcher setting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCloseLauncher() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.loadCloseLauncher) {
|
||||
const enabled = await window.electronAPI.loadCloseLauncher();
|
||||
if (closeLauncherCheck) {
|
||||
closeLauncherCheck.checked = enabled;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading close launcher setting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function savePlayerName() {
|
||||
try {
|
||||
if (!window.electronAPI || !settingsPlayerName) return;
|
||||
|
||||
const playerName = settingsPlayerName.value.trim();
|
||||
|
||||
if (!playerName) {
|
||||
const msg = window.i18n ? window.i18n.t('notifications.playerNameRequired') : 'Please enter a valid player name';
|
||||
showNotification(msg, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await window.electronAPI.saveUsername(playerName);
|
||||
const successMsg = window.i18n ? window.i18n.t('notifications.playerNameSaved') : 'Player name saved successfully';
|
||||
showNotification(successMsg, 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving player name:', error);
|
||||
const errorMsg = window.i18n ? window.i18n.t('notifications.playerNameSaveFailed') : 'Failed to save player name';
|
||||
showNotification(errorMsg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlayerName() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.loadUsername && settingsPlayerName) {
|
||||
if (!window.electronAPI || !settingsPlayerName) return;
|
||||
|
||||
const savedName = await window.electronAPI.loadUsername();
|
||||
if (savedName) {
|
||||
settingsPlayerName.value = savedName;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading player name:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGpuPreference() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.saveGpuPreference && gpuPreferenceRadios) {
|
||||
const gpuPreference = Array.from(gpuPreferenceRadios).find(radio => radio.checked)?.value || 'auto';
|
||||
await window.electronAPI.saveGpuPreference(gpuPreference);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving GPU preference:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGpuLabel() {
|
||||
const detectionInfo = document.getElementById('gpu-detection-info');
|
||||
if (!detectionInfo) return;
|
||||
|
||||
if (gpuPreferenceRadios) {
|
||||
const checked = Array.from(gpuPreferenceRadios).find(radio => radio.checked);
|
||||
if (checked) {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.getDetectedGpu) {
|
||||
const detected = await window.electronAPI.getDetectedGpu();
|
||||
if (checked.value === 'auto') {
|
||||
if (detected.dedicatedName) {
|
||||
detectionInfo.textContent = `dGPU detected, using ${detected.dedicatedName}`;
|
||||
} else {
|
||||
detectionInfo.textContent = `dGPU not detected, using iGPU (${detected.integratedName}) instead`;
|
||||
}
|
||||
detectionInfo.style.display = 'block';
|
||||
} else if (checked.value === 'integrated') {
|
||||
detectionInfo.textContent = `Detected: ${detected.integratedName}`;
|
||||
detectionInfo.style.display = 'block';
|
||||
} else if (checked.value === 'dedicated') {
|
||||
if (detected.dedicatedName) {
|
||||
detectionInfo.textContent = `Detected: ${detected.dedicatedName}`;
|
||||
} else {
|
||||
detectionInfo.textContent = `No dedicated GPU detected`;
|
||||
}
|
||||
detectionInfo.style.display = 'block';
|
||||
} else {
|
||||
detectionInfo.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting detected GPU:', error);
|
||||
detectionInfo.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
detectionInfo.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
detectionInfo.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGpuPreference() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.loadGpuPreference && gpuPreferenceRadios) {
|
||||
const savedPreference = await window.electronAPI.loadGpuPreference();
|
||||
if (savedPreference) {
|
||||
for (const radio of gpuPreferenceRadios) {
|
||||
if (radio.value === savedPreference) {
|
||||
radio.checked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
await updateGpuLabel();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading GPU preference:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllSettings() {
|
||||
await loadCustomJavaPath();
|
||||
await loadPlayerName();
|
||||
await loadCurrentUuid();
|
||||
await loadDiscordRPC();
|
||||
await loadCloseLauncher();
|
||||
await loadGpuPreference();
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
@@ -135,9 +526,369 @@ export function getCurrentPlayerName() {
|
||||
return 'Player';
|
||||
}
|
||||
|
||||
window.openGameLocation = openGameLocation;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initSettings);
|
||||
|
||||
window.SettingsAPI = {
|
||||
getCurrentJavaPath,
|
||||
getCurrentPlayerName
|
||||
};
|
||||
|
||||
async function loadCurrentUuid() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.getCurrentUuid) {
|
||||
const uuid = await window.electronAPI.getCurrentUuid();
|
||||
if (uuid) {
|
||||
if (currentUuidDisplay) currentUuidDisplay.value = uuid;
|
||||
if (modalCurrentUuid) modalCurrentUuid.value = uuid;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading current UUID:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCurrentUuid() {
|
||||
try {
|
||||
const uuid = currentUuidDisplay ? currentUuidDisplay.value : modalCurrentUuid?.value;
|
||||
if (uuid && navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(uuid);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidCopied') : 'UUID copied to clipboard!';
|
||||
showNotification(msg, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying UUID:', error);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidCopyFailed') : 'Failed to copy UUID';
|
||||
showNotification(msg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateCurrentUuid() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.resetCurrentUserUuid) {
|
||||
const message = window.i18n ? window.i18n.t('confirm.regenerateUuidMessage') : 'Are you sure you want to generate a new UUID? This will change your player identity.';
|
||||
const title = window.i18n ? window.i18n.t('confirm.regenerateUuidTitle') : 'Generate New UUID';
|
||||
const confirmBtn = window.i18n ? window.i18n.t('confirm.regenerateUuidButton') : 'Generate';
|
||||
const cancelBtn = window.i18n ? window.i18n.t('common.cancel') : 'Cancel';
|
||||
|
||||
showCustomConfirm(
|
||||
message,
|
||||
title,
|
||||
async () => {
|
||||
await performRegenerateUuid();
|
||||
},
|
||||
null,
|
||||
confirmBtn,
|
||||
cancelBtn
|
||||
);
|
||||
} else {
|
||||
console.error('electronAPI.resetCurrentUserUuid not available');
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidRegenNotAvailable') : 'UUID regeneration not available';
|
||||
showNotification(msg, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in regenerateCurrentUuid:', error);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidRegenFailed') : 'Failed to regenerate UUID';
|
||||
showNotification(msg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function performRegenerateUuid() {
|
||||
try {
|
||||
const result = await window.electronAPI.resetCurrentUserUuid();
|
||||
if (result.success && result.uuid) {
|
||||
if (currentUuidDisplay) currentUuidDisplay.value = result.uuid;
|
||||
if (modalCurrentUuid) modalCurrentUuid.value = result.uuid;
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidGenerated') : 'New UUID generated successfully!';
|
||||
showNotification(msg, 'success');
|
||||
|
||||
if (uuidModal && uuidModal.style.display !== 'none') {
|
||||
await loadAllUuids();
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to generate new UUID');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error regenerating UUID:', error);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidRegenFailed').replace('{error}', error.message) : `Failed to regenerate UUID: ${error.message}`;
|
||||
showNotification(msg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function openUuidModal() {
|
||||
try {
|
||||
if (uuidModal) {
|
||||
uuidModal.style.display = 'flex';
|
||||
uuidModal.classList.add('active');
|
||||
await loadAllUuids();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error opening UUID modal:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function closeUuidModal() {
|
||||
if (uuidModal) {
|
||||
uuidModal.classList.remove('active');
|
||||
setTimeout(() => {
|
||||
uuidModal.style.display = 'none';
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllUuids() {
|
||||
try {
|
||||
if (!uuidList) return;
|
||||
|
||||
uuidList.innerHTML = `
|
||||
<div class="uuid-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
Loading UUIDs...
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (window.electronAPI && window.electronAPI.getAllUuidMappings) {
|
||||
const mappings = await window.electronAPI.getAllUuidMappings();
|
||||
|
||||
if (mappings.length === 0) {
|
||||
uuidList.innerHTML = `
|
||||
<div class="uuid-loading">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
No UUIDs found
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
uuidList.innerHTML = '';
|
||||
|
||||
for (const mapping of mappings) {
|
||||
const item = document.createElement('div');
|
||||
item.className = `uuid-list-item${mapping.isCurrent ? ' current' : ''}`;
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="uuid-item-info">
|
||||
<div class="uuid-item-username">${escapeHtml(mapping.username)}</div>
|
||||
<div class="uuid-item-uuid">${mapping.uuid}</div>
|
||||
</div>
|
||||
<div class="uuid-item-actions">
|
||||
${mapping.isCurrent ? '<div class="uuid-item-current-badge">Current</div>' : ''}
|
||||
<button class="uuid-item-btn copy" onclick="copyUuid('${mapping.uuid}')" title="Copy UUID">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
${!mapping.isCurrent ? `<button class="uuid-item-btn delete" onclick="deleteUuid('${escapeHtml(mapping.username)}')" title="Delete UUID">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
uuidList.appendChild(item);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading UUIDs:', error);
|
||||
if (uuidList) {
|
||||
uuidList.innerHTML = `
|
||||
<div class="uuid-loading">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
Error loading UUIDs
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function generateNewUuid() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.generateNewUuid) {
|
||||
const newUuid = await window.electronAPI.generateNewUuid();
|
||||
if (newUuid) {
|
||||
if (customUuidInput) customUuidInput.value = newUuid;
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidGeneratedShort') : 'New UUID generated!';
|
||||
showNotification(msg, 'success');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating new UUID:', error);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidGenerateFailed') : 'Failed to generate new UUID';
|
||||
showNotification(msg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function setCustomUuid() {
|
||||
try {
|
||||
if (!customUuidInput || !customUuidInput.value.trim()) {
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidRequired') : 'Please enter a UUID';
|
||||
showNotification(msg, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const uuid = customUuidInput.value.trim();
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(uuid)) {
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidInvalidFormat') : 'Invalid UUID format';
|
||||
showNotification(msg, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = window.i18n ? window.i18n.t('confirm.setCustomUuidMessage') : 'Are you sure you want to set this custom UUID? This will change your player identity.';
|
||||
const title = window.i18n ? window.i18n.t('confirm.setCustomUuidTitle') : 'Set Custom UUID';
|
||||
const confirmBtn = window.i18n ? window.i18n.t('confirm.setCustomUuidButton') : 'Set UUID';
|
||||
const cancelBtn = window.i18n ? window.i18n.t('common.cancel') : 'Cancel';
|
||||
|
||||
showCustomConfirm(
|
||||
message,
|
||||
title,
|
||||
async () => {
|
||||
await performSetCustomUuid(uuid);
|
||||
},
|
||||
null,
|
||||
confirmBtn,
|
||||
cancelBtn
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in setCustomUuid:', error);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidSetFailed') : 'Failed to set custom UUID';
|
||||
showNotification(msg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function performSetCustomUuid(uuid) {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.setUuidForUser) {
|
||||
const username = getCurrentPlayerName();
|
||||
const result = await window.electronAPI.setUuidForUser(username, uuid);
|
||||
|
||||
if (result.success) {
|
||||
if (currentUuidDisplay) currentUuidDisplay.value = uuid;
|
||||
if (modalCurrentUuid) modalCurrentUuid.value = uuid;
|
||||
if (customUuidInput) customUuidInput.value = '';
|
||||
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidSetSuccess') : 'Custom UUID set successfully!';
|
||||
showNotification(msg, 'success');
|
||||
|
||||
await loadAllUuids();
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to set custom UUID');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting custom UUID:', error);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidSetFailed').replace('{error}', error.message) : `Failed to set custom UUID: ${error.message}`;
|
||||
showNotification(msg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
window.copyUuid = async function(uuid) {
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(uuid);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidCopied') : 'UUID copied to clipboard!';
|
||||
showNotification(msg, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying UUID:', error);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidCopyFailed') : 'Failed to copy UUID';
|
||||
showNotification(msg, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window.deleteUuid = async function(username) {
|
||||
try {
|
||||
const message = window.i18n ? window.i18n.t('confirm.deleteUuidMessage').replace('{username}', username) : `Are you sure you want to delete the UUID for "${username}"? This action cannot be undone.`;
|
||||
const title = window.i18n ? window.i18n.t('confirm.deleteUuidTitle') : 'Delete UUID';
|
||||
const confirmBtn = window.i18n ? window.i18n.t('confirm.deleteUuidButton') : 'Delete';
|
||||
const cancelBtn = window.i18n ? window.i18n.t('common.cancel') : 'Cancel';
|
||||
|
||||
showCustomConfirm(
|
||||
message,
|
||||
title,
|
||||
async () => {
|
||||
await performDeleteUuid(username);
|
||||
},
|
||||
null,
|
||||
confirmBtn,
|
||||
cancelBtn
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in deleteUuid:', error);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidDeleteFailed') : 'Failed to delete UUID';
|
||||
showNotification(msg, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
async function performDeleteUuid(username) {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.deleteUuidForUser) {
|
||||
const result = await window.electronAPI.deleteUuidForUser(username);
|
||||
|
||||
if (result.success) {
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidDeleteSuccess') : 'UUID deleted successfully!';
|
||||
showNotification(msg, 'success');
|
||||
await loadAllUuids();
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to delete UUID');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting UUID:', error);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidDeleteFailed').replace('{error}', error.message) : `Failed to delete UUID: ${error.message}`;
|
||||
showNotification(msg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
transition: all 0.3s ease;
|
||||
`;
|
||||
|
||||
if (type === 'success') {
|
||||
notification.style.background = 'linear-gradient(135deg, #22c55e, #16a34a)';
|
||||
} else if (type === 'error') {
|
||||
notification.style.background = 'linear-gradient(135deg, #ef4444, #dc2626)';
|
||||
} else {
|
||||
notification.style.background = 'linear-gradient(135deg, #3b82f6, #2563eb)';
|
||||
}
|
||||
|
||||
notification.innerHTML = `
|
||||
<i class="fas fa-${type === 'success' ? 'check' : type === 'error' ? 'exclamation-triangle' : 'info-circle'}"></i>
|
||||
${message}
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
168
GUI/js/ui.js
168
GUI/js/ui.js
@@ -49,6 +49,13 @@ function setupWindowControls() {
|
||||
const windowControls = document.querySelector('.window-controls');
|
||||
const header = document.querySelector('.header');
|
||||
|
||||
const profileSelector = document.querySelector('.profile-selector');
|
||||
|
||||
if (profileSelector) {
|
||||
profileSelector.style.pointerEvents = 'auto';
|
||||
profileSelector.style.zIndex = '10000';
|
||||
}
|
||||
|
||||
if (windowControls) {
|
||||
windowControls.style.pointerEvents = 'auto';
|
||||
windowControls.style.zIndex = '10000';
|
||||
@@ -59,6 +66,9 @@ function setupWindowControls() {
|
||||
if (windowControls) {
|
||||
windowControls.style.webkitAppRegion = 'no-drag';
|
||||
}
|
||||
if (profileSelector) {
|
||||
profileSelector.style.webkitAppRegion = 'no-drag';
|
||||
}
|
||||
}
|
||||
|
||||
if (window.electronAPI) {
|
||||
@@ -196,8 +206,26 @@ function setupFirstLaunchHandlers() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -338,7 +366,7 @@ function lockPlayButton(locked) {
|
||||
if (!playButton.getAttribute('data-original-text')) {
|
||||
playButton.setAttribute('data-original-text', spanElement.textContent);
|
||||
}
|
||||
spanElement.textContent = 'CHECKING...';
|
||||
spanElement.textContent = window.i18n ? window.i18n.t('play.checking') : 'CHECKING...';
|
||||
}
|
||||
|
||||
console.log('Play button locked');
|
||||
@@ -349,9 +377,9 @@ function lockPlayButton(locked) {
|
||||
playButton.removeAttribute('data-locked');
|
||||
|
||||
const spanElement = playButton.querySelector('span');
|
||||
const originalText = playButton.getAttribute('data-original-text');
|
||||
if (spanElement && originalText) {
|
||||
spanElement.textContent = originalText;
|
||||
if (spanElement) {
|
||||
// Use i18n to get the current translation instead of restoring saved text
|
||||
spanElement.textContent = window.i18n ? window.i18n.t('play.playButton') : 'PLAY HYTALE';
|
||||
playButton.removeAttribute('data-original-text');
|
||||
}
|
||||
|
||||
@@ -365,7 +393,8 @@ async function acceptFirstLaunchUpdate() {
|
||||
const existingGame = window.firstLaunchExistingGame;
|
||||
|
||||
if (!existingGame) {
|
||||
showNotification('Error: Game data not found', 'error');
|
||||
const errorMsg = window.i18n ? window.i18n.t('notifications.gameDataNotFound') : 'Error: Game data not found';
|
||||
showNotification(errorMsg, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -382,7 +411,8 @@ async function acceptFirstLaunchUpdate() {
|
||||
|
||||
try {
|
||||
showProgress();
|
||||
updateProgress({ message: 'Starting mandatory game update...', percent: 0 });
|
||||
const updateMsg = window.i18n ? window.i18n.t('progress.startingUpdate') : 'Starting mandatory game update...';
|
||||
updateProgress({ message: updateMsg, percent: 0 });
|
||||
|
||||
const result = await window.electronAPI.acceptFirstLaunchUpdate(existingGame);
|
||||
|
||||
@@ -396,10 +426,12 @@ async function acceptFirstLaunchUpdate() {
|
||||
|
||||
if (result.success) {
|
||||
hideProgress();
|
||||
showNotification('Game updated successfully! 🎉', 'success');
|
||||
const successMsg = window.i18n ? window.i18n.t('notifications.gameUpdatedSuccess') : 'Game updated successfully! 🎉';
|
||||
showNotification(successMsg, 'success');
|
||||
} else {
|
||||
hideProgress();
|
||||
showNotification(`Update failed: ${result.error}`, 'error');
|
||||
const errorMsg = window.i18n ? window.i18n.t('notifications.updateFailed').replace('{error}', result.error) : `Update failed: ${result.error}`;
|
||||
showNotification(errorMsg, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (modal) {
|
||||
@@ -407,7 +439,8 @@ async function acceptFirstLaunchUpdate() {
|
||||
}
|
||||
lockPlayButton(false);
|
||||
hideProgress();
|
||||
showNotification(`Update error: ${error.message}`, 'error');
|
||||
const errorMsg = window.i18n ? window.i18n.t('notifications.updateError').replace('{error}', error.message) : `Update error: ${error.message}`;
|
||||
showNotification(errorMsg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,17 +479,47 @@ function setupUI() {
|
||||
progressSpeed = document.getElementById('progressSpeed');
|
||||
progressSize = document.getElementById('progressSize');
|
||||
|
||||
// Setup draggable progress bar
|
||||
setupProgressDrag();
|
||||
|
||||
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();
|
||||
loadLauncherVersion();
|
||||
|
||||
document.body.focus();
|
||||
}
|
||||
|
||||
// Load launcher version from package.json
|
||||
async function loadLauncherVersion() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.getVersion) {
|
||||
const version = await window.electronAPI.getVersion();
|
||||
const versionElement = document.getElementById('launcherVersion');
|
||||
if (versionElement) {
|
||||
versionElement.textContent = `v${version}`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load launcher version:', error);
|
||||
}
|
||||
}
|
||||
|
||||
window.LauncherUI = {
|
||||
showPage,
|
||||
setActiveNav,
|
||||
@@ -466,4 +529,91 @@ window.LauncherUI = {
|
||||
updateProgress
|
||||
};
|
||||
|
||||
// Make installation effects globally available
|
||||
window.showInstallationEffects = showInstallationEffects;
|
||||
window.hideInstallationEffects = hideInstallationEffects;
|
||||
|
||||
// Draggable progress bar functionality
|
||||
function setupProgressDrag() {
|
||||
if (!progressOverlay) return;
|
||||
|
||||
let isDragging = false;
|
||||
let offsetX;
|
||||
let offsetY;
|
||||
|
||||
progressOverlay.addEventListener('mousedown', dragStart);
|
||||
document.addEventListener('mousemove', drag);
|
||||
document.addEventListener('mouseup', dragEnd);
|
||||
|
||||
function dragStart(e) {
|
||||
// Only drag if clicking on the overlay itself, not on buttons or inputs
|
||||
if (e.target.closest('.progress-bar-fill')) return;
|
||||
|
||||
if (e.target === progressOverlay || e.target.closest('.progress-content')) {
|
||||
isDragging = true;
|
||||
progressOverlay.classList.add('dragging');
|
||||
|
||||
// Get the current position of the progress overlay
|
||||
const rect = progressOverlay.getBoundingClientRect();
|
||||
offsetX = e.clientX - rect.left - progressOverlay.offsetWidth / 2;
|
||||
offsetY = e.clientY - rect.top;
|
||||
}
|
||||
}
|
||||
|
||||
function drag(e) {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
|
||||
// Calculate new position
|
||||
const newX = e.clientX - offsetX - progressOverlay.offsetWidth / 2;
|
||||
const newY = e.clientY - offsetY;
|
||||
|
||||
// Get window bounds
|
||||
const maxX = window.innerWidth - progressOverlay.offsetWidth;
|
||||
const maxY = window.innerHeight - progressOverlay.offsetHeight;
|
||||
const minX = 0;
|
||||
const minY = 0;
|
||||
|
||||
// Constrain to window bounds
|
||||
const constrainedX = Math.max(minX, Math.min(newX, maxX));
|
||||
const constrainedY = Math.max(minY, Math.min(newY, maxY));
|
||||
|
||||
progressOverlay.style.left = constrainedX + 'px';
|
||||
progressOverlay.style.bottom = 'auto';
|
||||
progressOverlay.style.top = constrainedY + 'px';
|
||||
progressOverlay.style.transform = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function dragEnd() {
|
||||
isDragging = false;
|
||||
progressOverlay.classList.remove('dragging');
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide installation effects
|
||||
function showInstallationEffects() {
|
||||
const installationEffects = document.getElementById('installationEffects');
|
||||
if (installationEffects) {
|
||||
installationEffects.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function hideInstallationEffects() {
|
||||
const installationEffects = document.getElementById('installationEffects');
|
||||
if (installationEffects) {
|
||||
installationEffects.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle maximize/restore window function
|
||||
function toggleMaximize() {
|
||||
if (window.electronAPI && window.electronAPI.maximizeWindow) {
|
||||
window.electronAPI.maximizeWindow();
|
||||
}
|
||||
}
|
||||
|
||||
// Make toggleMaximize globally available
|
||||
window.toggleMaximize = toggleMaximize;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', setupUI);
|
||||
|
||||
214
GUI/js/update.js
214
GUI/js/update.js
@@ -10,6 +10,23 @@ class ClientUpdateManager {
|
||||
this.showUpdatePopup(updateInfo);
|
||||
});
|
||||
|
||||
// Listen for electron-updater events
|
||||
window.electronAPI.onUpdateAvailable((updateInfo) => {
|
||||
this.showUpdatePopup(updateInfo);
|
||||
});
|
||||
|
||||
window.electronAPI.onUpdateDownloadProgress((progress) => {
|
||||
this.updateDownloadProgress(progress);
|
||||
});
|
||||
|
||||
window.electronAPI.onUpdateDownloaded((updateInfo) => {
|
||||
this.showUpdateDownloaded(updateInfo);
|
||||
});
|
||||
|
||||
window.electronAPI.onUpdateError((errorInfo) => {
|
||||
this.handleUpdateError(errorInfo);
|
||||
});
|
||||
|
||||
this.checkForUpdatesOnDemand();
|
||||
}
|
||||
|
||||
@@ -33,23 +50,46 @@ class ClientUpdateManager {
|
||||
<div class="update-popup-versions">
|
||||
<div class="version-row">
|
||||
<span class="version-label">Current Version:</span>
|
||||
<span class="version-current">${updateInfo.currentVersion}</span>
|
||||
<span class="version-current">${updateInfo.currentVersion || updateInfo.version || 'Unknown'}</span>
|
||||
</div>
|
||||
<div class="version-row">
|
||||
<span class="version-label">New Version:</span>
|
||||
<span class="version-new">${updateInfo.newVersion}</span>
|
||||
<span class="version-new">${updateInfo.newVersion || updateInfo.version || 'Unknown'}</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.
|
||||
<span id="update-status-text">Downloading update automatically...</span>
|
||||
<div id="update-error-message" style="display: none; margin-top: 0.75rem; padding: 0.75rem; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); border-radius: 0.5rem; color: #fca5a5; font-size: 0.875rem;">
|
||||
<i class="fas fa-exclamation-triangle" style="margin-right: 0.5rem;"></i>
|
||||
<span id="update-error-text"></span>
|
||||
</div>
|
||||
</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
|
||||
<div id="update-progress-container" style="display: none; margin-bottom: 1rem;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.75rem; color: #9ca3af;">
|
||||
<span id="update-progress-percent">0%</span>
|
||||
<span id="update-progress-speed">0 KB/s</span>
|
||||
</div>
|
||||
<div style="width: 100%; height: 8px; background: rgba(255, 255, 255, 0.1); border-radius: 4px; overflow: hidden;">
|
||||
<div id="update-progress-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #3b82f6, #9333ea); transition: width 0.3s ease;"></div>
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem; font-size: 0.75rem; color: #9ca3af; text-align: center;">
|
||||
<span id="update-progress-size">0 MB / 0 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="update-buttons-container" style="display: none;">
|
||||
<button id="update-install-btn" class="update-download-btn">
|
||||
<i class="fas fa-check" style="margin-right: 0.5rem;"></i>
|
||||
Install & Restart
|
||||
</button>
|
||||
<button id="update-download-btn" class="update-download-btn update-download-btn-secondary" style="margin-top: 0.75rem;">
|
||||
<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>
|
||||
Manually Download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="update-popup-footer">
|
||||
This popup cannot be closed until you update the launcher
|
||||
@@ -62,6 +102,31 @@ class ClientUpdateManager {
|
||||
|
||||
this.blockInterface();
|
||||
|
||||
// Show progress container immediately (auto-download is enabled)
|
||||
const progressContainer = document.getElementById('update-progress-container');
|
||||
if (progressContainer) {
|
||||
progressContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
const installBtn = document.getElementById('update-install-btn');
|
||||
if (installBtn) {
|
||||
installBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
installBtn.disabled = true;
|
||||
installBtn.innerHTML = '<i class="fas fa-spinner fa-spin" style="margin-right: 0.5rem;"></i>Installing...';
|
||||
|
||||
try {
|
||||
await window.electronAPI.quitAndInstallUpdate();
|
||||
} catch (error) {
|
||||
console.error('❌ Error installing update:', error);
|
||||
installBtn.disabled = false;
|
||||
installBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Install & Restart';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const downloadBtn = document.getElementById('update-download-btn');
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', async (e) => {
|
||||
@@ -80,7 +145,7 @@ class ClientUpdateManager {
|
||||
} 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';
|
||||
downloadBtn.innerHTML = '<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>Manually Download';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -99,6 +164,134 @@ class ClientUpdateManager {
|
||||
console.log('🔔 Update popup displayed with new style');
|
||||
}
|
||||
|
||||
updateDownloadProgress(progress) {
|
||||
const progressBar = document.getElementById('update-progress-bar');
|
||||
const progressPercent = document.getElementById('update-progress-percent');
|
||||
const progressSpeed = document.getElementById('update-progress-speed');
|
||||
const progressSize = document.getElementById('update-progress-size');
|
||||
|
||||
if (progressBar && progress) {
|
||||
const percent = Math.round(progress.percent || 0);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
|
||||
if (progressPercent) {
|
||||
progressPercent.textContent = `${percent}%`;
|
||||
}
|
||||
|
||||
if (progressSpeed && progress.bytesPerSecond) {
|
||||
const speedMBps = (progress.bytesPerSecond / 1024 / 1024).toFixed(2);
|
||||
progressSpeed.textContent = `${speedMBps} MB/s`;
|
||||
}
|
||||
|
||||
if (progressSize && progress.transferred && progress.total) {
|
||||
const transferredMB = (progress.transferred / 1024 / 1024).toFixed(2);
|
||||
const totalMB = (progress.total / 1024 / 1024).toFixed(2);
|
||||
progressSize.textContent = `${transferredMB} MB / ${totalMB} MB`;
|
||||
}
|
||||
|
||||
// Don't update status text here - it's already set and the progress bar shows the percentage
|
||||
}
|
||||
}
|
||||
|
||||
showUpdateDownloaded(updateInfo) {
|
||||
const statusText = document.getElementById('update-status-text');
|
||||
const progressContainer = document.getElementById('update-progress-container');
|
||||
const buttonsContainer = document.getElementById('update-buttons-container');
|
||||
|
||||
if (statusText) {
|
||||
statusText.textContent = 'Update downloaded! Ready to install.';
|
||||
}
|
||||
|
||||
if (progressContainer) {
|
||||
progressContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
if (buttonsContainer) {
|
||||
buttonsContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
console.log('✅ Update downloaded, ready to install');
|
||||
}
|
||||
|
||||
handleUpdateError(errorInfo) {
|
||||
console.error('Update error:', errorInfo);
|
||||
|
||||
// If manual download is required, update the UI (this will handle status text)
|
||||
if (errorInfo.requiresManualDownload) {
|
||||
this.showManualDownloadRequired(errorInfo);
|
||||
return; // Don't do anything else, showManualDownloadRequired handles everything
|
||||
}
|
||||
|
||||
// For non-critical errors, just show error message without changing status
|
||||
const errorMessage = document.getElementById('update-error-message');
|
||||
const errorText = document.getElementById('update-error-text');
|
||||
|
||||
if (errorMessage && errorText) {
|
||||
let message = errorInfo.message || 'An error occurred during the update process.';
|
||||
if (errorInfo.isMacSigningError) {
|
||||
message = 'Auto-update requires code signing. Please download manually.';
|
||||
}
|
||||
errorText.textContent = message;
|
||||
errorMessage.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
showManualDownloadRequired(errorInfo) {
|
||||
const statusText = document.getElementById('update-status-text');
|
||||
const progressContainer = document.getElementById('update-progress-container');
|
||||
const buttonsContainer = document.getElementById('update-buttons-container');
|
||||
const installBtn = document.getElementById('update-install-btn');
|
||||
const downloadBtn = document.getElementById('update-download-btn');
|
||||
const errorMessage = document.getElementById('update-error-message');
|
||||
const errorText = document.getElementById('update-error-text');
|
||||
|
||||
// Hide progress and install button
|
||||
if (progressContainer) {
|
||||
progressContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
if (installBtn) {
|
||||
installBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update status message (only once, don't change it again)
|
||||
if (statusText && !statusText.dataset.manualMode) {
|
||||
statusText.textContent = 'Please download and install the update manually.';
|
||||
statusText.dataset.manualMode = 'true'; // Mark that we've set manual mode
|
||||
}
|
||||
|
||||
// Show error message with details
|
||||
if (errorMessage && errorText) {
|
||||
let message = 'Auto-update is not available. ';
|
||||
if (errorInfo.isMacSigningError) {
|
||||
message = 'This app requires code signing for automatic updates.';
|
||||
} else if (errorInfo.isLinuxInstallError) {
|
||||
message = 'Auto-installation requires root privileges. Please download and install the update manually using your package manager.';
|
||||
} else if (errorInfo.message) {
|
||||
message = errorInfo.message;
|
||||
} else {
|
||||
message = 'An error occurred during the update process.';
|
||||
}
|
||||
errorText.textContent = message;
|
||||
errorMessage.style.display = 'block';
|
||||
}
|
||||
|
||||
// Show and enable the manual download button (make it primary since it's the only option)
|
||||
if (downloadBtn) {
|
||||
downloadBtn.style.display = 'block';
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.classList.remove('update-download-btn-secondary');
|
||||
downloadBtn.innerHTML = '<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>Download Update Manually';
|
||||
}
|
||||
|
||||
// Show buttons container if not already visible
|
||||
if (buttonsContainer) {
|
||||
buttonsContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
console.log('⚠️ Manual download required due to update error');
|
||||
}
|
||||
|
||||
blockInterface() {
|
||||
const mainContent = document.querySelector('.flex.w-full.h-screen');
|
||||
if (mainContent) {
|
||||
@@ -144,7 +337,12 @@ class ClientUpdateManager {
|
||||
async checkForUpdatesOnDemand() {
|
||||
try {
|
||||
const updateInfo = await window.electronAPI.checkForUpdates();
|
||||
if (updateInfo.updateAvailable) {
|
||||
|
||||
// Double-check that versions are actually different before showing popup
|
||||
if (updateInfo.updateAvailable &&
|
||||
updateInfo.newVersion &&
|
||||
updateInfo.currentVersion &&
|
||||
updateInfo.newVersion !== updateInfo.currentVersion) {
|
||||
this.showUpdatePopup(updateInfo);
|
||||
}
|
||||
return updateInfo;
|
||||
|
||||
231
GUI/locales/en.json
Normal file
231
GUI/locales/en.json
Normal file
@@ -0,0 +1,231 @@
|
||||
{
|
||||
"nav": {
|
||||
"play": "Play",
|
||||
"mods": "Mods",
|
||||
"news": "News",
|
||||
"chat": "Players Chat",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"header": {
|
||||
"playersLabel": "Players:",
|
||||
"manageProfiles": "Manage Profiles",
|
||||
"defaultProfile": "Default"
|
||||
},
|
||||
"install": {
|
||||
"title": "FREE TO PLAY LAUNCHER",
|
||||
"playerName": "Player Name",
|
||||
"playerNamePlaceholder": "Enter your name",
|
||||
"customInstallation": "Custom Installation",
|
||||
"installationFolder": "Installation Folder",
|
||||
"pathPlaceholder": "Default location",
|
||||
"browse": "Browse",
|
||||
"installButton": "INSTALL HYTALE",
|
||||
"installing": "INSTALLING..."
|
||||
},
|
||||
"play": {
|
||||
"ready": "READY TO PLAY",
|
||||
"subtitle": "Launch Hytale and enter the adventure",
|
||||
"playButton": "PLAY HYTALE",
|
||||
"latestNews": "LATEST NEWS",
|
||||
"viewAll": "VIEW ALL",
|
||||
"checking": "CHECKING...",
|
||||
"play": "PLAY"
|
||||
},
|
||||
"mods": {
|
||||
"searchPlaceholder": "Search mods...",
|
||||
"myMods": "MY MODS",
|
||||
"previous": "PREVIOUS",
|
||||
"next": "NEXT",
|
||||
"page": "Page",
|
||||
"of": "of",
|
||||
"modalTitle": "MY MODS",
|
||||
"noModsFound": "No Mods Found",
|
||||
"noModsFoundDesc": "Try adjusting your search",
|
||||
"noModsInstalled": "No Mods Installed",
|
||||
"noModsInstalledDesc": "Add mods from CurseForge or import local files",
|
||||
"view": "VIEW",
|
||||
"install": "INSTALL",
|
||||
"installed": "INSTALLED",
|
||||
"enable": "ENABLE",
|
||||
"disable": "DISABLE",
|
||||
"active": "ACTIVE",
|
||||
"disabled": "DISABLED",
|
||||
"delete": "Delete mod",
|
||||
"noDescription": "No description available",
|
||||
"confirmDelete": "Are you sure you want to delete \"{name}\"?",
|
||||
"confirmDeleteDesc": "This action cannot be undone.",
|
||||
"confirmDeletion": "Confirm Deletion"
|
||||
},
|
||||
"news": {
|
||||
"title": "ALL NEWS",
|
||||
"readMore": "Read More"
|
||||
},
|
||||
"chat": {
|
||||
"title": "PLAYERS CHAT",
|
||||
"pickColor": "Color",
|
||||
"inputPlaceholder": "Type your message...",
|
||||
"send": "Send",
|
||||
"online": "online",
|
||||
"charCounter": "{current}/{max}",
|
||||
"secureChat": "Secure chat - Links are censored",
|
||||
"joinChat": "Join Chat",
|
||||
"chooseUsername": "Choose a username to join the Players Chat",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "Enter your username...",
|
||||
"usernameHint": "3-20 characters, letters, numbers, - and _ only",
|
||||
"joinButton": "Join Chat",
|
||||
"colorModal": {
|
||||
"title": "Customize Username Color",
|
||||
"chooseSolid": "Choose a solid color:",
|
||||
"customColor": "Custom color:",
|
||||
"preview": "Preview:",
|
||||
"previewUsername": "Username",
|
||||
"apply": "Apply Color"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "SETTINGS",
|
||||
"java": "Java Runtime",
|
||||
"useCustomJava": "Use Custom Java Path",
|
||||
"javaDescription": "Override the bundled Java runtime with your own installation",
|
||||
"javaPath": "Java Executable Path",
|
||||
"javaPathPlaceholder": "Select Java path...",
|
||||
"javaBrowse": "Browse",
|
||||
"javaHint": "Select the Java installation folder (supports Windows, Mac, Linux)",
|
||||
"discord": "Discord Integration",
|
||||
"enableRPC": "Enable Discord Rich Presence",
|
||||
"discordDescription": "Show your launcher activity on Discord",
|
||||
"game": "Game Options",
|
||||
"playerName": "Player Name",
|
||||
"playerNamePlaceholder": "Enter your player name",
|
||||
"playerNameHint": "This name will be used in-game (1-16 characters)",
|
||||
"openGameLocation": "Open Game Location",
|
||||
"openGameLocationDesc": "Open the game installation folder",
|
||||
"account": "Player UUID Management",
|
||||
"currentUUID": "Current UUID",
|
||||
"uuidPlaceholder": "Loading UUID...",
|
||||
"copyUUID": "Copy UUID",
|
||||
"regenerateUUID": "Regenerate UUID",
|
||||
"uuidHint": "Your unique player identifier for this username",
|
||||
"manageUUIDs": "Manage All UUIDs",
|
||||
"manageUUIDsDesc": "View and manage all player UUIDs",
|
||||
"language": "Language",
|
||||
"selectLanguage": "Select Language",
|
||||
"repairGame": "Repair Game",
|
||||
"reinstallGame": "Reinstall game files (preserves data)",
|
||||
"gpuPreference": "GPU Preference",
|
||||
"gpuHint": "Select your preferred GPU (Linux: affects DRI_PRIME)",
|
||||
"gpuAuto": "Auto",
|
||||
"gpuIntegrated": "Integrated",
|
||||
"gpuDedicated": "Dedicated",
|
||||
"logs": "SYSTEM LOGS",
|
||||
"logsCopy": "Copy",
|
||||
"logsRefresh": "Refresh",
|
||||
"logsFolder": "Open Folder",
|
||||
"logsLoading": "Loading logs...",
|
||||
"closeLauncher": "Launcher Behavior",
|
||||
"closeOnStart": "Close Launcher on game start",
|
||||
"closeOnStartDescription": "Automatically close the launcher after Hytale has launched"
|
||||
},
|
||||
"uuid": {
|
||||
"modalTitle": "UUID Management",
|
||||
"currentUserUUID": "Current User UUID",
|
||||
"allPlayerUUIDs": "All Player UUIDs",
|
||||
"generateNew": "Generate New UUID",
|
||||
"loadingUUIDs": "Loading UUIDs...",
|
||||
"setCustomUUID": "Set Custom UUID",
|
||||
"customPlaceholder": "Enter custom UUID (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
|
||||
"setUUID": "Set UUID",
|
||||
"warning": "Warning: Setting a custom UUID will change your current player identity",
|
||||
"copyTooltip": "Copy UUID",
|
||||
"regenerateTooltip": "Generate New UUID"
|
||||
},
|
||||
"profiles": {
|
||||
"modalTitle": "Manage Profiles",
|
||||
"newProfilePlaceholder": "New Profile Name",
|
||||
"createProfile": "Create Profile"
|
||||
},
|
||||
"discord": {
|
||||
"notificationText": "Join our Discord community!",
|
||||
"joinButton": "Join Discord"
|
||||
},
|
||||
"common": {
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"loading": "Loading...",
|
||||
"apply": "Apply"
|
||||
},
|
||||
"notifications": {
|
||||
"gameDataNotFound": "Error: Game data not found",
|
||||
"gameUpdatedSuccess": "Game updated successfully! 🎉",
|
||||
"updateFailed": "Update failed: {error}",
|
||||
"updateError": "Update error: {error}",
|
||||
"discordEnabled": "Discord Rich Presence enabled",
|
||||
"discordDisabled": "Discord Rich Presence disabled",
|
||||
"discordSaveFailed": "Failed to save Discord setting",
|
||||
"playerNameRequired": "Please enter a valid player name",
|
||||
"playerNameSaved": "Player name saved successfully",
|
||||
"playerNameSaveFailed": "Failed to save player name",
|
||||
"uuidCopied": "UUID copied to clipboard!",
|
||||
"uuidCopyFailed": "Failed to copy UUID",
|
||||
"uuidRegenNotAvailable": "UUID regeneration not available",
|
||||
"uuidRegenFailed": "Failed to regenerate UUID",
|
||||
"uuidGenerated": "New UUID generated successfully!",
|
||||
"uuidGeneratedShort": "New UUID generated!",
|
||||
"uuidGenerateFailed": "Failed to generate new UUID",
|
||||
"uuidRequired": "Please enter a UUID",
|
||||
"uuidInvalidFormat": "Invalid UUID format",
|
||||
"uuidSetFailed": "Failed to set custom UUID",
|
||||
"uuidSetSuccess": "Custom UUID set successfully!",
|
||||
"uuidDeleteFailed": "Failed to delete UUID",
|
||||
"uuidDeleteSuccess": "UUID deleted successfully!",
|
||||
"modsDownloading": "Downloading {name}...",
|
||||
"modsTogglingMod": "Toggling mod...",
|
||||
"modsDeletingMod": "Deleting mod...",
|
||||
"modsLoadingMods": "Loading mods from CurseForge...",
|
||||
"modsInstalledSuccess": "{name} installed successfully! 🎉",
|
||||
"modsDeletedSuccess": "{name} deleted successfully",
|
||||
"modsDownloadFailed": "Failed to download mod: {error}",
|
||||
"modsToggleFailed": "Failed to toggle mod: {error}",
|
||||
"modsDeleteFailed": "Failed to delete mod: {error}",
|
||||
"modsModNotFound": "Mod information not found"
|
||||
},
|
||||
"confirm": {
|
||||
"defaultTitle": "Confirm action",
|
||||
"regenerateUuidTitle": "Generate new UUID",
|
||||
"regenerateUuidMessage": "Are you sure you want to generate a new UUID? This will change your player identity.",
|
||||
"regenerateUuidButton": "Generate",
|
||||
"setCustomUuidTitle": "Set custom UUID",
|
||||
"setCustomUuidMessage": "Are you sure you want to set this custom UUID? This will change your player identity.",
|
||||
"setCustomUuidButton": "Set UUID",
|
||||
"deleteUuidTitle": "Delete UUID",
|
||||
"deleteUuidMessage": "Are you sure you want to delete the UUID for \"{username}\"? This action cannot be undone.",
|
||||
"deleteUuidButton": "Delete",
|
||||
"uninstallGameTitle": "Uninstall game",
|
||||
"uninstallGameMessage": "Are you sure you want to uninstall Hytale? All game files will be deleted.",
|
||||
"uninstallGameButton": "Uninstall"
|
||||
},
|
||||
"progress": {
|
||||
"initializing": "Initializing...",
|
||||
"downloading": "Downloading...",
|
||||
"installing": "Installing...",
|
||||
"extracting": "Extracting...",
|
||||
"verifying": "Verifying...",
|
||||
"switchingProfile": "Switching profile...",
|
||||
"profileSwitched": "Profile switched!",
|
||||
"startingGame": "Starting game...",
|
||||
"launching": "LAUNCHING...",
|
||||
"uninstallingGame": "Uninstalling game...",
|
||||
"gameUninstalled": "Game uninstalled successfully!",
|
||||
"uninstallFailed": "Uninstall failed: {error}",
|
||||
"startingUpdate": "Starting mandatory game update...",
|
||||
"installationComplete": "Installation completed successfully!",
|
||||
"installationFailed": "Installation failed: {error}",
|
||||
"installingGameFiles": "Installing game files...",
|
||||
"installComplete": "Installation complete!"
|
||||
}
|
||||
}
|
||||
231
GUI/locales/es.json
Normal file
231
GUI/locales/es.json
Normal file
@@ -0,0 +1,231 @@
|
||||
{
|
||||
"nav": {
|
||||
"play": "Jugar",
|
||||
"mods": "Mods",
|
||||
"news": "Noticias",
|
||||
"chat": "Chat de Jugadores",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"header": {
|
||||
"playersLabel": "Jugadores:",
|
||||
"manageProfiles": "Gestionar Perfiles",
|
||||
"defaultProfile": "Predeterminado"
|
||||
},
|
||||
"install": {
|
||||
"title": "LAUNCHER GRATUITO",
|
||||
"playerName": "Nombre del Jugador",
|
||||
"playerNamePlaceholder": "Ingresa tu nombre",
|
||||
"customInstallation": "Instalación Personalizada",
|
||||
"installationFolder": "Carpeta de Instalación",
|
||||
"pathPlaceholder": "Ubicación predeterminada",
|
||||
"browse": "Examinar",
|
||||
"installButton": "INSTALAR HYTALE",
|
||||
"installing": "INSTALANDO..."
|
||||
},
|
||||
"play": {
|
||||
"ready": "LISTO PARA JUGAR",
|
||||
"subtitle": "Inicia Hytale y entra en la aventura",
|
||||
"playButton": "JUGAR HYTALE",
|
||||
"latestNews": "ÚLTIMAS NOTICIAS",
|
||||
"viewAll": "VER TODO",
|
||||
"checking": "VERIFICANDO...",
|
||||
"play": "JUGAR"
|
||||
},
|
||||
"mods": {
|
||||
"searchPlaceholder": "Buscar mods...",
|
||||
"myMods": "MIS MODS",
|
||||
"previous": "ANTERIOR",
|
||||
"next": "SIGUIENTE",
|
||||
"page": "Página",
|
||||
"of": "de",
|
||||
"modalTitle": "MIS MODS",
|
||||
"noModsFound": "No se encontraron mods",
|
||||
"noModsFoundDesc": "Intenta ajustar tu búsqueda",
|
||||
"noModsInstalled": "No hay mods instalados",
|
||||
"noModsInstalledDesc": "Añade mods desde CurseForge o importa archivos locales",
|
||||
"view": "VER",
|
||||
"install": "INSTALAR",
|
||||
"installed": "INSTALADO",
|
||||
"enable": "ACTIVAR",
|
||||
"disable": "DESACTIVAR",
|
||||
"active": "ACTIVO",
|
||||
"disabled": "DESACTIVADO",
|
||||
"delete": "Eliminar mod",
|
||||
"noDescription": "Sin descripción disponible",
|
||||
"confirmDelete": "¿Estás seguro de que quieres eliminar \"{name}\"?",
|
||||
"confirmDeleteDesc": "Esta acción no se puede deshacer.",
|
||||
"confirmDeletion": "Confirmar eliminación"
|
||||
},
|
||||
"news": {
|
||||
"title": "TODAS LAS NOTICIAS",
|
||||
"readMore": "Leer más"
|
||||
},
|
||||
"chat": {
|
||||
"title": "CHAT DE JUGADORES",
|
||||
"pickColor": "Color",
|
||||
"inputPlaceholder": "Escribe tu mensaje...",
|
||||
"send": "Enviar",
|
||||
"online": "en línea",
|
||||
"charCounter": "{current}/{max}",
|
||||
"secureChat": "Chat seguro - Los enlaces están censurados",
|
||||
"joinChat": "Unirse al chat",
|
||||
"chooseUsername": "Elige un nombre de usuario para unirte al chat de jugadores",
|
||||
"username": "Nombre de usuario",
|
||||
"usernamePlaceholder": "Ingresa tu nombre de usuario...",
|
||||
"usernameHint": "3-20 caracteres, letras, números, - y _ solamente",
|
||||
"joinButton": "Unirse al Chat",
|
||||
"colorModal": {
|
||||
"title": "Personalizar color del nombre",
|
||||
"chooseSolid": "Elige un color sólido:",
|
||||
"customColor": "Color personalizado:",
|
||||
"preview": "Vista previa:",
|
||||
"previewUsername": "Nombre de usuario",
|
||||
"apply": "Aplicar color"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "CONFIGURACIÓN",
|
||||
"java": "Entorno Java",
|
||||
"useCustomJava": "Usar ruta de Java personalizada",
|
||||
"javaDescription": "Reemplaza el entorno Java incluido con tu propia instalación",
|
||||
"javaPath": "Ruta del ejecutable Java",
|
||||
"javaPathPlaceholder": "Selecciona la ruta de Java...",
|
||||
"javaBrowse": "Examinar",
|
||||
"javaHint": "Selecciona la carpeta de instalación de Java (compatible con Windows, Mac, Linux)",
|
||||
"discord": "Integración con Discord",
|
||||
"enableRPC": "Habilitar Discord Rich Presence",
|
||||
"discordDescription": "Muestra tu actividad del launcher en Discord",
|
||||
"game": "Opciones del juego",
|
||||
"playerName": "Nombre del jugador",
|
||||
"playerNamePlaceholder": "Ingresa tu nombre de jugador",
|
||||
"playerNameHint": "Este nombre se usará en el juego (1-16 caracteres)",
|
||||
"openGameLocation": "Abrir ubicación del juego",
|
||||
"openGameLocationDesc": "Abre la carpeta de instalación del juego",
|
||||
"account": "Gestión de UUID del jugador",
|
||||
"currentUUID": "UUID actual",
|
||||
"uuidPlaceholder": "Cargando UUID...",
|
||||
"copyUUID": "Copiar UUID",
|
||||
"regenerateUUID": "Regenerar UUID",
|
||||
"uuidHint": "Tu identificador único de jugador para este nombre de usuario",
|
||||
"manageUUIDs": "Gestionar todos los UUIDs",
|
||||
"manageUUIDsDesc": "Ver y gestionar todos los UUIDs de jugadores",
|
||||
"language": "Idioma",
|
||||
"selectLanguage": "Seleccionar idioma",
|
||||
"repairGame": "Reparar juego",
|
||||
"reinstallGame": "Reinstalar archivos del juego (conserva los datos)",
|
||||
"gpuPreference": "Preferencia de GPU",
|
||||
"gpuHint": "Selecciona tu GPU preferida (Linux: afecta DRI_PRIME)",
|
||||
"gpuAuto": "Automático",
|
||||
"gpuIntegrated": "Integrada",
|
||||
"gpuDedicated": "Dedicada",
|
||||
"logs": "REGISTROS DEL SISTEMA",
|
||||
"logsCopy": "Copiar",
|
||||
"logsRefresh": "Actualizar",
|
||||
"logsFolder": "Abrir Carpeta",
|
||||
"logsLoading": "Cargando registros...",
|
||||
"closeLauncher": "Comportamiento del Launcher",
|
||||
"closeOnStart": "Cerrar Launcher al iniciar el juego",
|
||||
"closeOnStartDescription": "Cierra automáticamente el launcher después de que Hytale se haya iniciado"
|
||||
},
|
||||
"uuid": {
|
||||
"modalTitle": "Gestión de UUID",
|
||||
"currentUserUUID": "UUID del usuario actual",
|
||||
"allPlayerUUIDs": "Todos los UUIDs de jugadores",
|
||||
"generateNew": "Generar nuevo UUID",
|
||||
"loadingUUIDs": "Cargando UUIDs...",
|
||||
"setCustomUUID": "Establecer UUID personalizado",
|
||||
"customPlaceholder": "Ingresa un UUID personalizado (formato: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
|
||||
"setUUID": "Establecer UUID",
|
||||
"warning": "Advertencia: Establecer un UUID personalizado cambiará tu identidad de jugador actual",
|
||||
"copyTooltip": "Copiar UUID",
|
||||
"regenerateTooltip": "Generar nuevo UUID"
|
||||
},
|
||||
"profiles": {
|
||||
"modalTitle": "Gestionar perfiles",
|
||||
"newProfilePlaceholder": "Nombre del nuevo perfil",
|
||||
"createProfile": "Crear perfil"
|
||||
},
|
||||
"discord": {
|
||||
"notificationText": "¡Únete a nuestra comunidad de Discord!",
|
||||
"joinButton": "Unirse a Discord"
|
||||
},
|
||||
"common": {
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"close": "Cerrar",
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"loading": "Cargando...",
|
||||
"apply": "Aplicar"
|
||||
},
|
||||
"notifications": {
|
||||
"gameDataNotFound": "Error: No se encontraron datos del juego",
|
||||
"gameUpdatedSuccess": "¡Juego actualizado con éxito! 🎉",
|
||||
"updateFailed": "Actualización fallida: {error}",
|
||||
"updateError": "Error de actualización: {error}",
|
||||
"discordEnabled": "Discord Rich Presence habilitado",
|
||||
"discordDisabled": "Discord Rich Presence deshabilitado",
|
||||
"discordSaveFailed": "Error al guardar la configuración de Discord",
|
||||
"playerNameRequired": "Por favor ingresa un nombre de jugador válido",
|
||||
"playerNameSaved": "Nombre de jugador guardado con éxito",
|
||||
"playerNameSaveFailed": "Error al guardar el nombre de jugador",
|
||||
"uuidCopied": "¡UUID copiado al portapapeles!",
|
||||
"uuidCopyFailed": "Error al copiar UUID",
|
||||
"uuidRegenNotAvailable": "Regeneración de UUID no disponible",
|
||||
"uuidRegenFailed": "Error al regenerar UUID",
|
||||
"uuidGenerated": "¡Nuevo UUID generado con éxito!",
|
||||
"uuidGeneratedShort": "¡Nuevo UUID generado!",
|
||||
"uuidGenerateFailed": "Error al generar nuevo UUID",
|
||||
"uuidRequired": "Por favor ingresa un UUID",
|
||||
"uuidInvalidFormat": "Formato de UUID inválido",
|
||||
"uuidSetFailed": "Error al establecer UUID personalizado",
|
||||
"uuidSetSuccess": "¡UUID personalizado establecido con éxito!",
|
||||
"uuidDeleteFailed": "Error al eliminar UUID",
|
||||
"uuidDeleteSuccess": "¡UUID eliminado con éxito!",
|
||||
"modsDownloading": "Descargando {name}...",
|
||||
"modsTogglingMod": "Alternando mod...",
|
||||
"modsDeletingMod": "Eliminando mod...",
|
||||
"modsLoadingMods": "Cargando mods desde CurseForge...",
|
||||
"modsInstalledSuccess": "¡{name} instalado con éxito! 🎉",
|
||||
"modsDeletedSuccess": "{name} eliminado con éxito",
|
||||
"modsDownloadFailed": "Error al descargar mod: {error}",
|
||||
"modsToggleFailed": "Error al alternar mod: {error}",
|
||||
"modsDeleteFailed": "Error al eliminar mod: {error}",
|
||||
"modsModNotFound": "Información del mod no encontrada"
|
||||
},
|
||||
"confirm": {
|
||||
"defaultTitle": "Confirmar acción",
|
||||
"regenerateUuidTitle": "Generar nuevo UUID",
|
||||
"regenerateUuidMessage": "¿Estás seguro de que quieres generar un nuevo UUID? Esto cambiará tu identidad de jugador.",
|
||||
"regenerateUuidButton": "Generar",
|
||||
"setCustomUuidTitle": "Establecer UUID personalizado",
|
||||
"setCustomUuidMessage": "¿Estás seguro de que quieres establecer este UUID personalizado? Esto cambiará tu identidad de jugador.",
|
||||
"setCustomUuidButton": "Establecer UUID",
|
||||
"deleteUuidTitle": "Eliminar UUID",
|
||||
"deleteUuidMessage": "¿Estás seguro de que quieres eliminar el UUID de \"{username}\"? Esta acción no se puede deshacer.",
|
||||
"deleteUuidButton": "Eliminar",
|
||||
"uninstallGameTitle": "Desinstalar juego",
|
||||
"uninstallGameMessage": "¿Estás seguro de que quieres desinstalar Hytale? Se eliminarán todos los archivos del juego.",
|
||||
"uninstallGameButton": "Desinstalar"
|
||||
},
|
||||
"progress": {
|
||||
"initializing": "Inicializando...",
|
||||
"downloading": "Descargando...",
|
||||
"installing": "Instalando...",
|
||||
"extracting": "Extrayendo...",
|
||||
"verifying": "Verificando...",
|
||||
"switchingProfile": "Cambiando perfil...",
|
||||
"profileSwitched": "¡Perfil cambiado!",
|
||||
"startingGame": "Iniciando juego...",
|
||||
"launching": "INICIANDO...",
|
||||
"uninstallingGame": "Desinstalando juego...",
|
||||
"gameUninstalled": "¡Juego desinstalado con éxito!",
|
||||
"uninstallFailed": "Desinstalación fallida: {error}",
|
||||
"startingUpdate": "Iniciando actualización obligatoria del juego...",
|
||||
"installationComplete": "¡Instalación completada con éxito!",
|
||||
"installationFailed": "Instalación fallida: {error}",
|
||||
"installingGameFiles": "Instalando archivos del juego...",
|
||||
"installComplete": "¡Instalación completa!"
|
||||
}
|
||||
}
|
||||
232
GUI/locales/pt-BR.json
Normal file
232
GUI/locales/pt-BR.json
Normal file
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"nav": {
|
||||
"play": "Jogar",
|
||||
"mods": "Mods",
|
||||
"news": "Notícias",
|
||||
"chat": "Chat de Jogadores",
|
||||
"settings": "Configurações"
|
||||
},
|
||||
"header": {
|
||||
"playersLabel": "Jogadores:",
|
||||
"manageProfiles": "Gerenciar Perfis",
|
||||
"defaultProfile": "Padrão"
|
||||
},
|
||||
"install": {
|
||||
"title": "LANÇADOR JOGO GRATUITO",
|
||||
"playerName": "Nome do Jogador",
|
||||
"playerNamePlaceholder": "Digite seu nome",
|
||||
"customInstallation": "Instalação Personalizada",
|
||||
"installationFolder": "Pasta de Instalação",
|
||||
"pathPlaceholder": "Local padrão",
|
||||
"browse": "Procurar",
|
||||
"installButton": "INSTALAR HYTALE",
|
||||
"installing": "INSTALANDO..."
|
||||
},
|
||||
"play": {
|
||||
"ready": "PRONTO PARA JOGAR",
|
||||
"subtitle": "Inicie Hytale e entre na aventura",
|
||||
"playButton": "JOGAR HYTALE",
|
||||
"latestNews": "ÚLTIMAS NOTÍCIAS",
|
||||
"viewAll": "VER TUDO",
|
||||
"checking": "VERIFICANDO...",
|
||||
"play": "JOGAR"
|
||||
},
|
||||
"mods": {
|
||||
"searchPlaceholder": "Pesquisar mods...",
|
||||
"myMods": "MEUS MODS",
|
||||
"previous": "ANTERIOR",
|
||||
"next": "PRÓXIMO",
|
||||
"page": "Página",
|
||||
"of": "de",
|
||||
"modalTitle": "MEUS MODS",
|
||||
"noModsFound": "Nenhum mod encontrado",
|
||||
"noModsFoundDesc": "Tente ajustar sua pesquisa",
|
||||
"noModsInstalled": "Nenhum mod instalado",
|
||||
"noModsInstalledDesc": "Adicione mods do CurseForge ou importe arquivos locais",
|
||||
"view": "VER",
|
||||
"install": "INSTALAR",
|
||||
"installed": "INSTALADO",
|
||||
"enable": "ATIVAR",
|
||||
"disable": "DESATIVAR",
|
||||
"active": "ATIVO",
|
||||
"disabled": "DESATIVADO",
|
||||
"delete": "Excluir mod",
|
||||
"noDescription": "Nenhuma descrição disponível",
|
||||
"confirmDelete": "Tem certeza de que deseja excluir \"{name}\"?",
|
||||
"confirmDeleteDesc": "Esta ação não pode ser desfeita.",
|
||||
"confirmDeletion": "Confirmar exclusão"
|
||||
},
|
||||
"news": {
|
||||
"title": "TODAS AS NOTÍCIAS",
|
||||
"readMore": "Leia mais"
|
||||
},
|
||||
"chat": {
|
||||
"title": "CHAT DE JOGADORES",
|
||||
"pickColor": "Cor",
|
||||
"inputPlaceholder": "Digite sua mensagem...",
|
||||
"send": "Enviar",
|
||||
"online": "online",
|
||||
"charCounter": "{current}/{max}",
|
||||
"secureChat": "Chat seguro - Links são censurados",
|
||||
"joinChat": "Entrar no chat",
|
||||
"chooseUsername": "Escolha um nome de usuário para entrar no chat de jogadores",
|
||||
"username": "Nome de usuário",
|
||||
"usernamePlaceholder": "Digite seu nome de usuário...",
|
||||
"usernameHint": "3-20 caracteres, letras, números, - e _ apenas",
|
||||
"joinButton": "Entrar no Chat",
|
||||
"colorModal": {
|
||||
"title": "Personalizar cor do nome de usuário",
|
||||
"chooseSolid": "Escolha uma cor sólida:",
|
||||
"customColor": "Cor personalizada:",
|
||||
"preview": "Visualização:",
|
||||
"previewUsername": "Nome de usuário",
|
||||
"apply": "Aplicar cor"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "CONFIGURAÇÕES",
|
||||
"java": "Tempo de execução Java",
|
||||
"useCustomJava": "Usar caminho personalizado do Java",
|
||||
"javaDescription": "Substitua o tempo de execução Java incluído pela sua própria instalação",
|
||||
"javaPath": "Caminho do executável Java",
|
||||
"javaPathPlaceholder": "Selecione o caminho do Java...",
|
||||
"javaBrowse": "Procurar",
|
||||
"javaHint": "Selecione a pasta de instalação do Java (suporta Windows, Mac, Linux)",
|
||||
"discord": "Integração do Discord",
|
||||
"enableRPC": "Ativar Discord Rich Presence",
|
||||
"discordDescription": "Mostre sua atividade do lançador no Discord",
|
||||
"game": "Opções do jogo",
|
||||
"playerName": "Nome do jogador",
|
||||
"playerNamePlaceholder": "Digite seu nome de jogador",
|
||||
"playerNameHint": "Este nome será usado no jogo (1-16 caracteres)",
|
||||
"openGameLocation": "Abrir local do jogo",
|
||||
"openGameLocationDesc": "Abra a pasta de instalação do jogo",
|
||||
"account": "Gerenciamento de UUID do jogador",
|
||||
"currentUUID": "UUID atual",
|
||||
"uuidPlaceholder": "Carregando UUID...",
|
||||
"copyUUID": "Copiar UUID",
|
||||
"regenerateUUID": "Regenerar UUID",
|
||||
"uuidHint": "Seu identificador único de jogador para este nome de usuário",
|
||||
"manageUUIDs": "Gerenciar todos os UUIDs",
|
||||
"manageUUIDsDesc": "Ver e gerenciar todos os UUIDs de jogadores",
|
||||
"language": "Idioma",
|
||||
"selectLanguage": "Selecionar idioma",
|
||||
"repairGame": "Reparar jogo",
|
||||
"reinstallGame": "Reinstalar arquivos do jogo (mantém os dados)",
|
||||
"gpuPreference": "Preferência de GPU",
|
||||
"gpuHint": "Selecione sua GPU preferida (Linux: afeta o DRI_PRIME)",
|
||||
"gpuAuto": "Automático",
|
||||
"gpuIntegrated": "Integrada",
|
||||
"gpuDedicated": "Dedicada",
|
||||
"logs": "REGISTROS DO SISTEMA",
|
||||
"logsCopy": "Copiar",
|
||||
"logsRefresh": "Atualizar",
|
||||
"logsFolder": "Abrir Pasta",
|
||||
"logsLoading": "Carregando registros...",
|
||||
"closeLauncher": "Comportamento do Lançador",
|
||||
"closeOnStart": "Fechar Lançador ao iniciar o jogo",
|
||||
"closeOnStartDescription": "Fechar automaticamente o lançador após o Hytale ter sido iniciado"
|
||||
},
|
||||
"uuid": {
|
||||
"modalTitle": "Gerenciamento de UUID",
|
||||
"currentUserUUID": "UUID do usuário atual",
|
||||
"allPlayerUUIDs": "Todos os UUIDs de jogadores",
|
||||
"generateNew": "Gerar novo UUID",
|
||||
"loadingUUIDs": "Carregando UUIDs...",
|
||||
"setCustomUUID": "Definir UUID personalizado",
|
||||
"customPlaceholder": "Digite um UUID personalizado (formato: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
|
||||
"setUUID": "Definir UUID",
|
||||
"warning": "Aviso: Definir um UUID personalizado alterará sua identidade de jogador atual",
|
||||
"copyTooltip": "Copiar UUID",
|
||||
"regenerateTooltip": "Gerar novo UUID"
|
||||
},
|
||||
"profiles": {
|
||||
"modalTitle": "Gerenciar perfis",
|
||||
"newProfilePlaceholder": "Nome do novo perfil",
|
||||
"createProfile": "Criar perfil"
|
||||
},
|
||||
"discord": {
|
||||
"notificationText": "Junte-se à nossa comunidade do Discord!",
|
||||
"joinButton": "Entrar no Discord"
|
||||
},
|
||||
|
||||
"common": {
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Salvar",
|
||||
"close": "Fechar",
|
||||
"delete": "Excluir",
|
||||
"edit": "Editar",
|
||||
"loading": "Carregando...",
|
||||
"apply": "Aplicar"
|
||||
},
|
||||
"notifications": {
|
||||
"gameDataNotFound": "Erro: Dados do jogo não encontrados",
|
||||
"gameUpdatedSuccess": "Jogo atualizado com sucesso! 🎉",
|
||||
"updateFailed": "Falha na atualização: {error}",
|
||||
"updateError": "Erro de atualização: {error}",
|
||||
"discordEnabled": "Discord Rich Presence ativado",
|
||||
"discordDisabled": "Discord Rich Presence desativado",
|
||||
"discordSaveFailed": "Falha ao salvar configuração do Discord",
|
||||
"playerNameRequired": "Por favor, digite um nome de jogador válido",
|
||||
"playerNameSaved": "Nome do jogador salvo com sucesso",
|
||||
"playerNameSaveFailed": "Falha ao salvar o nome do jogador",
|
||||
"uuidCopied": "UUID copiado para a área de transferência!",
|
||||
"uuidCopyFailed": "Falha ao copiar UUID",
|
||||
"uuidRegenNotAvailable": "Regeneração de UUID não disponível",
|
||||
"uuidRegenFailed": "Falha ao regenerar UUID",
|
||||
"uuidGenerated": "Novo UUID gerado com sucesso!",
|
||||
"uuidGeneratedShort": "Novo UUID gerado!",
|
||||
"uuidGenerateFailed": "Falha ao gerar novo UUID",
|
||||
"uuidRequired": "Por favor, digite um UUID",
|
||||
"uuidInvalidFormat": "Formato de UUID inválido",
|
||||
"uuidSetFailed": "Falha ao definir UUID personalizado",
|
||||
"uuidSetSuccess": "UUID personalizado definido com sucesso!",
|
||||
"uuidDeleteFailed": "Falha ao excluir UUID",
|
||||
"uuidDeleteSuccess": "UUID excluído com sucesso!",
|
||||
"modsDownloading": "Baixando {name}...",
|
||||
"modsTogglingMod": "Alternando mod...",
|
||||
"modsDeletingMod": "Excluindo mod...",
|
||||
"modsLoadingMods": "Carregando mods do CurseForge...",
|
||||
"modsInstalledSuccess": "{name} instalado com sucesso! 🎉",
|
||||
"modsDeletedSuccess": "{name} excluído com sucesso",
|
||||
"modsDownloadFailed": "Falha ao baixar mod: {error}",
|
||||
"modsToggleFailed": "Falha ao alternar mod: {error}",
|
||||
"modsDeleteFailed": "Falha ao excluir mod: {error}",
|
||||
"modsModNotFound": "Informações do mod não encontradas"
|
||||
},
|
||||
"confirm": {
|
||||
"defaultTitle": "Confirmar ação",
|
||||
"regenerateUuidTitle": "Gerar novo UUID",
|
||||
"regenerateUuidMessage": "Tem certeza de que deseja gerar um novo UUID? Isso alterará sua identidade de jogador.",
|
||||
"regenerateUuidButton": "Gerar",
|
||||
"setCustomUuidTitle": "Definir UUID personalizado",
|
||||
"setCustomUuidMessage": "Tem certeza de que deseja definir este UUID personalizado? Isso alterará sua identidade de jogador.",
|
||||
"setCustomUuidButton": "Definir UUID",
|
||||
"deleteUuidTitle": "Excluir UUID",
|
||||
"deleteUuidMessage": "Tem certeza de que deseja excluir o UUID de \"{username}\"? Esta ação não pode ser desfeita.",
|
||||
"deleteUuidButton": "Excluir",
|
||||
"uninstallGameTitle": "Desinstalar jogo",
|
||||
"uninstallGameMessage": "Tem certeza de que deseja desinstalar Hytale? Todos os arquivos do jogo serão excluídos.",
|
||||
"uninstallGameButton": "Desinstalar"
|
||||
},
|
||||
"progress": {
|
||||
"initializing": "Inicializando...",
|
||||
"downloading": "Baixando...",
|
||||
"installing": "Instalando...",
|
||||
"extracting": "Extraindo...",
|
||||
"verifying": "Verificando...",
|
||||
"switchingProfile": "Alternando perfil...",
|
||||
"profileSwitched": "Perfil alternado!",
|
||||
"startingGame": "Iniciando jogo...",
|
||||
"launching": "INICIANDO...",
|
||||
"uninstallingGame": "Desinstalando jogo...",
|
||||
"gameUninstalled": "Jogo desinstalado com sucesso!",
|
||||
"uninstallFailed": "Falha na desinstalação: {error}",
|
||||
"startingUpdate": "Iniciando atualização obrigatória do jogo...",
|
||||
"installationComplete": "Instalação concluída com sucesso!",
|
||||
"installationFailed": "Falha na instalação: {error}",
|
||||
"installingGameFiles": "Instalando arquivos do jogo...",
|
||||
"installComplete": "Instalação concluída!"
|
||||
}
|
||||
}
|
||||
178
GUI/splash.html
Normal file
178
GUI/splash.html
Normal file
@@ -0,0 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hytale F2P</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.background img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.background::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.splash-container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
text-align: center;
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 2rem;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 30px rgba(147, 51, 234, 0.5));
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.title-accent {
|
||||
background: linear-gradient(135deg, #9333ea, #a855f7, #c084fc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 2rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 200px;
|
||||
height: 4px;
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
border-radius: 2px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loader::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #9333ea, #a855f7, #c084fc);
|
||||
animation: loading 1.5s ease-in-out infinite;
|
||||
box-shadow: 0 0 20px rgba(147, 51, 234, 0.6);
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
left: -100%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
animation: blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="background">
|
||||
<img src="https://assets.authbp.xyz/bg.png" alt="Background">
|
||||
</div>
|
||||
|
||||
<div class="splash-container">
|
||||
<div class="logo">
|
||||
<img src="./icon.png" alt="Hytale Logo">
|
||||
</div>
|
||||
<h1 class="title">
|
||||
HY<span class="title-accent">TALE</span>
|
||||
</h1>
|
||||
<p class="subtitle">FREE TO PLAY LAUNCHER</p>
|
||||
<div class="loader"></div>
|
||||
<p class="loading-text">Loading...</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1752
GUI/style.css
1752
GUI/style.css
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
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
|
||||
Exec=/opt/Hytale-F2P/hytale-f2p-launcherv2
|
||||
Categories=Game;
|
||||
Icon=Hytale-F2P
|
||||
Terminal=false
|
||||
|
||||
12
PKGBUILD
12
PKGBUILD
@@ -1,19 +1,20 @@
|
||||
# Maintainer: Terromur <terromuroz@proton.me>
|
||||
# Maintainer: Fazri Gading <fazrigading@gmail.com>
|
||||
pkgname=Hytale-F2P-git
|
||||
_pkgname=Hytale-F2P
|
||||
pkgver=2.0.0.r47.gebcfdc4
|
||||
pkgver=2.0.2b.r120.gb05aeef
|
||||
pkgrel=1
|
||||
pkgdesc="HyLauncher - unofficial Hytale Launcher for free to play gamers"
|
||||
pkgdesc="Hytale-F2P - unofficial Hytale Launcher for free to play with multiplayer support"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/amiayweb/Hytale-F2P"
|
||||
license=('custom')
|
||||
makedepends=('npm')
|
||||
makedepends=('npm' 'git' 'rpm-tools' 'libxcrypt-compat')
|
||||
source=("git+$url.git" "Hytale-F2P.desktop")
|
||||
sha256sums=('SKIP' '46488fada4775d9976d7b7b62f8d1f1f8d9a9a9d8f8aa9af4f2e2153019f6a30')
|
||||
sha256sums=('SKIP' '8c78a6931fade2b0501122980dc238e042b9f6f0292b5ca74c391d7b3c1543c0')
|
||||
|
||||
pkgver() {
|
||||
cd "$_pkgname"
|
||||
printf "2.0.0.r%s.g%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
printf "2.0.2b.r%s.g%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
@@ -28,4 +29,3 @@ package() {
|
||||
install -Dm644 "$_pkgname.desktop" "$pkgdir/usr/share/applications/$_pkgname.desktop"
|
||||
install -Dm644 "$_pkgname/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/$_pkgname.png"
|
||||
}
|
||||
|
||||
|
||||
100
README.md
100
README.md
@@ -1,19 +1,19 @@
|
||||
# 🎮 Hytale F2P Launcher | Multiplayer Support
|
||||
# 🎮 Hytale F2P Launcher | Multiplayer Support [Windows, MacOS, Linux]
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/MHkEjepMQ7)
|
||||
|
||||
**A modern, cross-platform offline launcher for Hytale with automatic updates and multiplayer support (windows users & non-premium only)**
|
||||
**A modern, cross-platform offline launcher for Hytale with automatic updates and multiplayer support (all OS supported)**
|
||||
|
||||
[](https://github.com/amiayweb/Hytale-F2P/stargazers)
|
||||
[](https://github.com/amiayweb/Hytale-F2P/network/members)
|
||||
|
||||
⭐ **If you find this project useful, please give it a star!** ⭐
|
||||
🛑 **Found a problem? Open an issue! I’m on Windows, so I can’t test on macOS or Linux.** 🛑
|
||||
|
||||
🛑 **Found a problem? Join the Discord: https://discord.gg/gME8rUy3MB** 🛑
|
||||
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
- 💾 **Data Preservation** - Intelligent UserData backup and restoration during updates
|
||||
- 🌐 **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)
|
||||
- 🎮 **Multiplayer Support** - Automatic multiplayer client installation (Windows, macOS & Linux !)
|
||||
|
||||
🛡️ **Advanced Features**
|
||||
- 📁 **Custom Installation** - Choose your own installation directory
|
||||
@@ -59,23 +59,14 @@
|
||||
3. Launch from desktop or start menu
|
||||
|
||||
#### Linux
|
||||
See [BUILD.md](BUILD.md) for detailed build instructions.
|
||||
See [BUILD.md](BUILD.md) for detailed build instructions or [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases) section.
|
||||
|
||||
#### macOS
|
||||
See [BUILD.md](BUILD.md) for detailed build instructions.
|
||||
See [BUILD.md](BUILD.md) for detailed build instructions or [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases) section.
|
||||
|
||||
#### 🖥️ How to create server (Windows Only)?
|
||||
1. Download the server files directly from: `http://3.10.208.30:3002/server`
|
||||
2. Replace the existing files in your `HytaleF2P` installation folder
|
||||
3. Run the server launcher (.bat) to start hosting your own Hytale server
|
||||
4. You will need a third party software like Radmin VPN (check on youtube how to use Radmin VPN).
|
||||
#### 🖥️ How to play online on F2P?
|
||||
See [SERVER.md](SERVER.md)
|
||||
|
||||
### 🎮 Usage
|
||||
|
||||
1. **Enter your player name**
|
||||
2. **Click "PLAY"**
|
||||
3. **Automatic setup** - The launcher handles everything automatically
|
||||
4. **Game launches** - Enjoy playing Hytale!
|
||||
|
||||
---
|
||||
|
||||
@@ -85,9 +76,60 @@ See [BUILD.md](BUILD.md) for comprehensive build instructions.
|
||||
|
||||
---
|
||||
|
||||
## 📌 Versioning Policy
|
||||
|
||||
**⚠️ Important: Semantic Versioning Required**
|
||||
|
||||
This project follows **strict semantic versioning** with **numerical versions only**:
|
||||
|
||||
- ✅ **Valid**: `2.0.1`, `2.0.11`, `2.1.0`, `3.0.0`
|
||||
- ❌ **Invalid**: `2.0.2b`, `2.0.2a`, `2.0.1-beta`, `v2.0.2b`
|
||||
|
||||
**Format**: `MAJOR.MINOR.PATCH` (e.g., `2.0.11`)
|
||||
|
||||
- **MAJOR**: Breaking changes
|
||||
- **MINOR**: New features (backward compatible)
|
||||
- **PATCH**: Bug fixes (backward compatible)
|
||||
|
||||
**Why?** The auto-update system requires semantic versioning for proper version comparison. Letter suffixes (like `2.0.2b`) are not supported and will cause update detection issues.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changelog
|
||||
|
||||
### 🆕 v2.0.0 *(Latest)*
|
||||
### 🆕 v2.0.2b *(Minor Update: Performance & Utilities)*
|
||||
- 🌎 **Language Translation** — A big welcome for Spanish 🇪🇸 and Portuguese (Brazil) 🇧🇷 players! **Language setting can be found in the bottom part of Settings pane.**
|
||||
- 💻 **Laptop/Hybrid GPU Performance Issue Fix** — Added automatic GPU detection system and options to choose which GPU will be used for the game, *specifically for Linux users*.
|
||||
- 👨💻 **In-App Logging** — Reporting bugs and issues to `Github Issues` tab or `Open A Ticket` channel in our Discord Server has been made easier for players, no more finding logs file manually.
|
||||
- 🛠️ **Repair Button** — Your game's broken? One button will fix them, go to Settings pane to Repair your game in one-click, **without losing any data**. If doing so did not fix your issue, please report it to us immediately!
|
||||
- 🐛 **Fixed Bugs** — Fixed issue [#84](https://github.com/amiayweb/Hytale-F2P/issues/84) where mods disappearing when game starts in previous launcher (v2.0.2a).
|
||||
|
||||
### 🆕 v2.0.2a *(Minor Update)*
|
||||
- 🧑🚀 **Profiles System** — Added proper profile management: create, switch, and delete profiles. Each profile now has its own **isolated mod list**.
|
||||
- 🔒 **Mod Isolation** — Fixed ModManager so mods are **strictly scoped to the active profile**. Browsing and installing now only affects the selected profile.
|
||||
- 🚨 **Critical Path Fix** — Resolved a macOS bug where mods were being saved to a Windows path (`~/AppData/Local`) instead of `~/Library/Application Support`. Mods now save to the **correct location** and load properly in-game.
|
||||
- 🛡️ **Stability Improvements** — Added an **auto-sync step before every launch** to ensure the physical mods folder always matches the active profile.
|
||||
- 🎨 **UI Enhancements** — Added a **profile selector dropdown** and a **profile management modal**.
|
||||
|
||||
### 🆕 v2.0.2
|
||||
- 🎮 **Discord RPC Integration** - Added Discord Rich Presence with toggle in settings (enabled by default)
|
||||
- 🌐 **Cross-Platform Multiplayer** - Added multiplayer patch support for Windows, Linux, and macOS
|
||||
- 🎨 **Chat Improvements** - Simplified chat color system
|
||||
- 🏆 **Badge System Expansion** - Added new FOUNDER UUID to the badge system
|
||||
- 🔧 **Progress Bar Fix** - Resolved issue where download progress bar stayed active after game launch
|
||||
- 🐛 **Bug Fixes**: General fixes
|
||||
|
||||
### 🔄 v2.0.1
|
||||
- 📊 **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
|
||||
@@ -129,14 +171,16 @@ See [BUILD.md](BUILD.md) for comprehensive build instructions.
|
||||
|
||||
### 🏆 Project Creator
|
||||
- [**@amiayweb**](https://github.com/amiayweb) - *Lead Developer & Project Creator*
|
||||
- [**@Relyz1993**](https://github.com/Relyz1993) - *Server Helper & Second Developer & Project Creator*
|
||||
|
||||
### 🌟 Contributors
|
||||
- [**@sanasol**](https://github.com/sanasol) - *Main Issues fixer | Multiplayer Patcher*
|
||||
- [**@Terromur**](https://github.com/Terromur) - *Main Issues fixer | Beta tester*
|
||||
- [**@fazrigading**](https://github.com/fazrigading) - *Main Issues fixer | Beta tester*
|
||||
- [**@ericiskoolbeans**](https://github.com/ericiskoolbeans) - *Beta Tester*
|
||||
- [**@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*
|
||||
|
||||
---
|
||||
|
||||
@@ -156,10 +200,7 @@ See [BUILD.md](BUILD.md) for comprehensive build instructions.
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/amiayweb/Hytale-F2P/issues)
|
||||
[](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)!
|
||||
**Need help?** Join us: https://discord.gg/gME8rUy3MB
|
||||
|
||||
</div>
|
||||
|
||||
@@ -187,11 +228,6 @@ This launcher is created for **educational purposes only**.
|
||||
|
||||
---
|
||||
|
||||
## 📬 Contact
|
||||
|
||||
[](https://discord.com/users/1433515183606599873)
|
||||
|
||||
|
||||
<div align="center">
|
||||
|
||||
**⭐ Star this project if you found it helpful! ⭐**
|
||||
|
||||
444
SERVER.md
Normal file
444
SERVER.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# Hytale F2P Server Guide
|
||||
|
||||
Play with friends online! This guide covers both easy in-game hosting and advanced dedicated server setup.
|
||||
|
||||
DOWNLOAD SERVER FILES HERE: https://discord.gg/MEyWUxt77m
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Playing with Friends (Online Play)
|
||||
|
||||
The easiest way to play with friends - no manual server setup required!
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Start the game** via F2P Launcher
|
||||
2. **Click "Online Play"** in the main menu
|
||||
3. **Share the invite code** with your friends
|
||||
4. Friends enter your invite code to join
|
||||
|
||||
The game automatically handles networking using UPnP/STUN/NAT traversal.
|
||||
|
||||
### Network Requirements
|
||||
|
||||
For Online Play to work, you need:
|
||||
|
||||
- **UPnP enabled** on your router (most routers have this on by default)
|
||||
- **Public IP address** from your ISP (not behind CGNAT)
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### "NAT Type: Carrier-Grade NAT (CGNAT)" Warning
|
||||
|
||||
If you see this message:
|
||||
```
|
||||
Connected via UPnP
|
||||
NAT Type: Carrier-Grade NAT (CGNAT)
|
||||
Warning: Your network configuration may prevent other players from connecting.
|
||||
```
|
||||
|
||||
**What this means:** Your ISP doesn't give you a public IP address. Multiple customers share one public IP, which blocks incoming connections.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Contact your ISP** - Request a public/static IP address (may cost extra)
|
||||
2. **Use a VPN with port forwarding** - Services like Mullvad, PIA, or AirVPN offer this
|
||||
3. **Use Radmin VPN or Playit.gg** - Create a virtual LAN with friends (see below)
|
||||
4. **Have a friend with public IP host instead**
|
||||
|
||||
#### "UPnP Failed" or "Port Mapping Failed"
|
||||
|
||||
**Check your router:**
|
||||
1. Log into router admin panel (usually `192.168.1.1` or `192.168.0.1`)
|
||||
2. Find UPnP settings (often under "Advanced" or "NAT")
|
||||
3. Enable UPnP if disabled
|
||||
4. Restart your router
|
||||
|
||||
**If UPnP isn't available:**
|
||||
- Manually forward **port 5520 UDP** to your computer's local IP
|
||||
- See "Port Forwarding" section below
|
||||
|
||||
#### "Strict NAT" or "Symmetric NAT"
|
||||
|
||||
Some routers have restrictive NAT that blocks peer connections.
|
||||
|
||||
**Try:**
|
||||
1. Enable "NAT Passthrough" or "NAT Filtering: Open" in router settings
|
||||
2. Put your device in router's DMZ (temporary test only)
|
||||
3. Use Radmin VPN as workaround
|
||||
|
||||
### Workarounds for NAT/CGNAT Issues
|
||||
|
||||
#### Option 1: playit.gg (Recommended)
|
||||
|
||||
Free tunneling service - only the host needs to install it:
|
||||
|
||||
1. **Download [playit.gg](https://playit.gg/)** and run it - Connect your account from the terminal (do not close it when playing on the server)
|
||||
2. **Add a tunnel** - Select "UDP", tunnel description of "Hytale Server", port count `1`, and local port `5520`
|
||||
3. **Start the tunnel** - You'll get a public address like `xx-xx.gl.at.ply.gg:5520`
|
||||
4. **Share the address** - Friends connect directly using this address
|
||||
|
||||
Works with both Online Play and dedicated servers. No software needed for players joining.
|
||||
|
||||
#### Option 2: Radmin VPN
|
||||
|
||||
Creates a virtual LAN - all players need to install it:
|
||||
|
||||
1. **Download [Radmin VPN](https://www.radmin-vpn.com/)** - All players install it
|
||||
2. **Create a network** - One person creates, others join with network name/password
|
||||
3. **Host via Online Play** - Use your Radmin VPN IP instead
|
||||
4. **Friends connect** - They'll see you on the virtual LAN
|
||||
|
||||
Both options bypass all NAT/CGNAT issues.
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Dedicated Server (Advanced)
|
||||
|
||||
For 24/7 servers, custom configurations, or hosting on a VPS/dedicated machine.
|
||||
|
||||
### Quick Start
|
||||
|
||||
#### Step 1: Get the Server JAR
|
||||
|
||||
The server scripts will automatically download the pre-patched server JAR if it's not present.
|
||||
|
||||
**Option A:** Let the scripts download automatically (requires `HYTALE_SERVER_URL` to be configured)
|
||||
|
||||
**Option B:** Manually place `HytaleServer.jar` (pre-patched for F2P) in the Server directory:
|
||||
|
||||
- **Windows:** `%localappdata%\HytaleF2P\release\package\game\latest\Server`
|
||||
- **macOS:** `~/Library/Application Support/HytaleF2P/release/package/game/latest/Server`
|
||||
- **Linux:** `~/.hytalef2p/release/package/game/latest/Server`
|
||||
|
||||
If you have a custom install path, the Server folder is inside your custom location under `HytaleF2P/release/package/game/latest/Server`.
|
||||
|
||||
#### Step 2: Run the Server
|
||||
|
||||
**Windows:**
|
||||
```batch
|
||||
cd scripts
|
||||
run_server.bat
|
||||
```
|
||||
|
||||
**macOS / Linux:**
|
||||
```bash
|
||||
cd scripts
|
||||
./run_server.sh
|
||||
```
|
||||
|
||||
The scripts will:
|
||||
1. Find your game installation automatically
|
||||
2. Download the pre-patched server JAR if needed
|
||||
3. Fetch session tokens from the auth server
|
||||
4. Start the server
|
||||
|
||||
#### Step 3: Connect Players
|
||||
|
||||
Share your server IP address with players. They connect via the F2P Launcher's server browser or direct connect.
|
||||
|
||||
---
|
||||
|
||||
## Network Setup (Dedicated Server)
|
||||
|
||||
### Local Network (LAN)
|
||||
|
||||
If all players are on the same network:
|
||||
1. Find your local IP: `ipconfig` (Windows) or `ifconfig` (Mac/Linux)
|
||||
2. Share this IP with players on your network
|
||||
3. Default port is `5520`
|
||||
|
||||
### Port Forwarding (Internet Play)
|
||||
|
||||
To allow direct internet connections:
|
||||
|
||||
1. Forward **port 5520 (UDP)** in your router
|
||||
2. Find your public IP at [whatismyip.com](https://whatismyip.com)
|
||||
3. Share your public IP with players
|
||||
|
||||
**Windows Firewall:**
|
||||
```powershell
|
||||
# Run as Administrator
|
||||
netsh advfirewall firewall add rule name="Hytale Server" dir=in action=allow protocol=UDP localport=5520
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set these before running to customize your server:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `HYTALE_SERVER_URL` | (placeholder) | URL to download pre-patched server JAR |
|
||||
| `HYTALE_AUTH_DOMAIN` | `sanasol.ws` | Auth server domain |
|
||||
| `HYTALE_BIND` | `0.0.0.0:5520` | Server IP and port |
|
||||
| `HYTALE_AUTH_MODE` | `authenticated` | Auth mode (see below) |
|
||||
| `HYTALE_SERVER_NAME` | `My Hytale Server` | Server display name |
|
||||
| `HYTALE_GAME_PATH` | (auto-detected) | Override game location |
|
||||
| `JVM_XMS` | `2G` | Minimum Java memory |
|
||||
| `JVM_XMX` | `4G` | Maximum Java memory |
|
||||
|
||||
**Example (Windows):**
|
||||
```batch
|
||||
set HYTALE_SERVER_NAME=My Awesome Server
|
||||
set JVM_XMX=8G
|
||||
run_server.bat
|
||||
```
|
||||
|
||||
**Example (Linux/macOS):**
|
||||
```bash
|
||||
HYTALE_SERVER_NAME="My Awesome Server" JVM_XMX=8G ./run_server.sh
|
||||
```
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
| Mode | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| `authenticated` | Players log in via F2P Launcher | Public servers |
|
||||
| `unauthenticated` | No login required | LAN parties, testing |
|
||||
| `singleplayer` | Local play only | Solo testing |
|
||||
|
||||
---
|
||||
|
||||
## RAM Allocation Guide
|
||||
|
||||
Adjust memory based on your system:
|
||||
|
||||
| System RAM | Players | JVM_XMS | JVM_XMX |
|
||||
|------------|---------|---------|---------|
|
||||
| 4 GB | 1-3 | `512M` | `2G` |
|
||||
| 8 GB | 3-8 | `1G` | `4G` |
|
||||
| 16 GB | 8-15 | `2G` | `8G` |
|
||||
| 32 GB | 15+ | `4G` | `12G` |
|
||||
|
||||
**Example for large server:**
|
||||
```bash
|
||||
JVM_XMS=4G JVM_XMX=12G ./run_server.sh
|
||||
```
|
||||
|
||||
**Tips:**
|
||||
- `-Xms` = minimum RAM (allocated at startup)
|
||||
- `-Xmx` = maximum RAM (upper limit)
|
||||
- Never allocate all your system RAM - leave room for OS
|
||||
- Start conservative and increase if needed
|
||||
|
||||
---
|
||||
|
||||
## Server Commands
|
||||
|
||||
Once running, use these commands in the console:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `help` | Show all commands |
|
||||
| `stop` | Stop server gracefully |
|
||||
| `save` | Force world save |
|
||||
| `list` | List online players |
|
||||
| `op <player>` | Give operator status |
|
||||
| `deop <player>` | Remove operator status |
|
||||
| `kick <player>` | Kick a player |
|
||||
| `ban <player>` | Ban a player |
|
||||
| `unban <player>` | Unban a player |
|
||||
| `tp <player> <x> <y> <z>` | Teleport player |
|
||||
|
||||
---
|
||||
|
||||
## Command Line Options
|
||||
|
||||
Pass these when starting the server:
|
||||
|
||||
```bash
|
||||
./run_server.sh [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--bind <ip:port>` | Server address (default: 0.0.0.0:5520) |
|
||||
| `--auth-mode <mode>` | Authentication mode |
|
||||
| `--universe <path>` | Path to world data |
|
||||
| `--mods <path>` | Path to mods folder |
|
||||
| `--backup` | Enable automatic backups |
|
||||
| `--backup-dir <path>` | Backup directory |
|
||||
| `--backup-frequency <mins>` | Backup interval |
|
||||
| `--owner-name <name>` | Server owner username |
|
||||
| `--allow-op` | Allow op commands |
|
||||
| `--disable-sentry` | Disable crash reporting |
|
||||
| `--help` | Show all options |
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
./run_server.sh --backup --backup-frequency 30 --allow-op
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
<game_path>/
|
||||
├── Assets.zip # Game assets (required)
|
||||
├── Client/ # Game client
|
||||
└── Server/
|
||||
├── HytaleServer.jar # Server executable (pre-patched)
|
||||
├── HytaleServer.aot # AOT cache (faster startup)
|
||||
├── universe/ # World data
|
||||
│ ├── world/ # Default world
|
||||
│ └── players/ # Player data
|
||||
├── mods/ # Server mods (optional)
|
||||
└── Licenses/ # License files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backups
|
||||
|
||||
### Automatic Backups
|
||||
|
||||
```bash
|
||||
./run_server.sh --backup --backup-dir ./backups --backup-frequency 30
|
||||
```
|
||||
|
||||
### Manual Backup
|
||||
|
||||
1. Use `save` command or stop the server
|
||||
2. Copy the `universe/` folder
|
||||
3. Store in a safe location
|
||||
|
||||
### Restore
|
||||
|
||||
1. Stop the server
|
||||
2. Delete/rename current `universe/`
|
||||
3. Copy backup to `universe/`
|
||||
4. Restart server
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Java not found" or "Java version too old"
|
||||
|
||||
**Java 21 is REQUIRED** (the server uses class file version 65.0).
|
||||
|
||||
**Install Java 21:**
|
||||
- **Windows:** `winget install EclipseAdoptium.Temurin.21.JDK`
|
||||
- **macOS:** `brew install openjdk@21`
|
||||
- **Ubuntu:** `sudo apt install openjdk-21-jdk`
|
||||
- **Fedora:** `sudo dnf install java-21-openjdk`
|
||||
- **Arch:** `sudo pacman -S jdk21-openjdk`
|
||||
- **Download:** [adoptium.net/temurin/releases/?version=21](https://adoptium.net/temurin/releases/?version=21)
|
||||
|
||||
**macOS: Set Java 21 as default:**
|
||||
```bash
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
```
|
||||
Add these lines to `~/.zshrc` or `~/.bash_profile` to make permanent.
|
||||
|
||||
### "Game directory not found"
|
||||
|
||||
- Download game via F2P Launcher first
|
||||
- Or set `HYTALE_GAME_PATH` environment variable
|
||||
- Check custom install path in launcher settings
|
||||
|
||||
### "Assets.zip not found"
|
||||
|
||||
Game files incomplete. Re-download via the launcher.
|
||||
|
||||
### "Port already in use"
|
||||
|
||||
```bash
|
||||
./run_server.sh --bind 0.0.0.0:5521
|
||||
```
|
||||
|
||||
### "Out of memory"
|
||||
|
||||
Increase JVM_XMX:
|
||||
```bash
|
||||
JVM_XMX=6G ./run_server.sh
|
||||
```
|
||||
|
||||
### Players can't connect
|
||||
|
||||
1. Server shows "Server Ready"?
|
||||
2. Using F2P Launcher (not official)?
|
||||
3. Port 5520 open in firewall?
|
||||
4. Port forwarding configured (for internet)?
|
||||
5. Try `--auth-mode unauthenticated` for testing
|
||||
|
||||
### "Authentication failed"
|
||||
|
||||
- Ensure players use F2P Launcher
|
||||
- Auth server may be temporarily down
|
||||
- Test with `--auth-mode unauthenticated`
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment (Advanced)
|
||||
|
||||
For production servers, use Docker:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name hytale-server \
|
||||
-p 5520:5520/udp \
|
||||
-v ./data:/data \
|
||||
-e HYTALE_AUTH_DOMAIN=sanasol.ws \
|
||||
-e HYTALE_SERVER_NAME="My Server" \
|
||||
-e JVM_XMX=8G \
|
||||
ghcr.io/hybrowse/hytale-server-docker:latest
|
||||
```
|
||||
|
||||
See [Docker documentation](https://github.com/Hybrowse/hytale-server-docker) for details.
|
||||
|
||||
---
|
||||
|
||||
## Server Settings Summary
|
||||
|
||||
### Minimal Setup
|
||||
```bash
|
||||
./run_server.sh
|
||||
```
|
||||
|
||||
### Custom Memory
|
||||
```bash
|
||||
JVM_XMS=2G JVM_XMX=8G ./run_server.sh
|
||||
```
|
||||
|
||||
### Custom Port
|
||||
```bash
|
||||
HYTALE_BIND=0.0.0.0:25565 ./run_server.sh
|
||||
```
|
||||
|
||||
### LAN Party (No Auth)
|
||||
```bash
|
||||
./run_server.sh --auth-mode unauthenticated
|
||||
```
|
||||
|
||||
### Full Custom Setup
|
||||
```bash
|
||||
HYTALE_SERVER_NAME="Epic Server" \
|
||||
HYTALE_BIND=0.0.0.0:5520 \
|
||||
JVM_XMS=2G \
|
||||
JVM_XMX=8G \
|
||||
./run_server.sh --backup --backup-frequency 15 --allow-op
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Check server console logs for errors
|
||||
- Test with `--auth-mode unauthenticated` first
|
||||
- Ensure all players have F2P Launcher
|
||||
- Join the community for support
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
- Hytale F2P Project
|
||||
- [Hybrowse Docker Image](https://github.com/Hybrowse/hytale-server-docker)
|
||||
- Auth Server: sanasol.ws
|
||||
379
backend/appUpdater.js
Normal file
379
backend/appUpdater.js
Normal file
@@ -0,0 +1,379 @@
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
const { app } = require('electron');
|
||||
const logger = require('./logger');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
class AppUpdater {
|
||||
constructor(mainWindow) {
|
||||
this.mainWindow = mainWindow;
|
||||
this.autoUpdateAvailable = true; // Track if auto-update is possible
|
||||
this.updateAvailable = false; // Track if an update was detected
|
||||
this.updateVersion = null; // Store the available update version
|
||||
this.setupAutoUpdater();
|
||||
}
|
||||
|
||||
setupAutoUpdater() {
|
||||
// Enable dev mode for testing (reads dev-app-update.yml)
|
||||
// Only enable in development, not in production builds
|
||||
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
|
||||
autoUpdater.forceDevUpdateConfig = true;
|
||||
console.log('Dev update mode enabled - using dev-app-update.yml');
|
||||
}
|
||||
|
||||
// Configure logger for electron-updater
|
||||
// Create a compatible logger interface
|
||||
autoUpdater.logger = {
|
||||
info: (...args) => logger.info(...args),
|
||||
warn: (...args) => logger.warn(...args),
|
||||
error: (...args) => logger.error(...args),
|
||||
debug: (...args) => logger.log(...args)
|
||||
};
|
||||
|
||||
// Auto download updates
|
||||
autoUpdater.autoDownload = true;
|
||||
// Auto install on quit (after download)
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Event handlers
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
console.log('Checking for updates...');
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-checking');
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
console.log('Update available:', info.version);
|
||||
const currentVersion = app.getVersion();
|
||||
const newVersion = info.version;
|
||||
|
||||
// Only proceed if the new version is actually different from current
|
||||
if (newVersion === currentVersion) {
|
||||
console.log('Update version matches current version, ignoring update-available event');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateAvailable = true;
|
||||
this.updateVersion = newVersion;
|
||||
this.autoUpdateAvailable = true; // Reset flag when new update is available
|
||||
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-available', {
|
||||
version: newVersion,
|
||||
newVersion: newVersion,
|
||||
currentVersion: currentVersion,
|
||||
releaseName: info.releaseName,
|
||||
releaseNotes: info.releaseNotes
|
||||
});
|
||||
// Also send to the old popup handler for compatibility
|
||||
this.mainWindow.webContents.send('show-update-popup', {
|
||||
currentVersion: currentVersion,
|
||||
newVersion: newVersion,
|
||||
version: newVersion
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
console.log('Update not available. Current version is latest.');
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-not-available', {
|
||||
version: info.version
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
console.error('Error in auto-updater:', err);
|
||||
|
||||
// Check if this is a network error (not critical, don't show UI)
|
||||
const errorMessage = err.message?.toLowerCase() || '';
|
||||
const isNetworkError = errorMessage.includes('err_name_not_resolved') ||
|
||||
errorMessage.includes('network') ||
|
||||
errorMessage.includes('connection') ||
|
||||
errorMessage.includes('timeout') ||
|
||||
errorMessage.includes('enotfound');
|
||||
|
||||
if (isNetworkError) {
|
||||
console.warn('Network error in auto-updater - will retry later. Not showing error UI.');
|
||||
return; // Don't show error UI for network issues
|
||||
}
|
||||
|
||||
// Handle SHA512 checksum mismatch - this can happen during updates, just retry
|
||||
const isChecksumError = err.code === 'ERR_CHECKSUM_MISMATCH' ||
|
||||
errorMessage.includes('sha512') ||
|
||||
errorMessage.includes('checksum') ||
|
||||
errorMessage.includes('mismatch');
|
||||
|
||||
if (isChecksumError) {
|
||||
console.warn('SHA512 checksum mismatch detected - clearing cache and will retry automatically. This is normal during updates.');
|
||||
// Clear the update cache and let it re-download
|
||||
this.clearUpdateCache();
|
||||
|
||||
// Don't show error UI - just log and let it retry automatically on next check
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this is a critical error that prevents auto-update
|
||||
const isCriticalError = this.isCriticalUpdateError(err);
|
||||
|
||||
if (isCriticalError) {
|
||||
this.autoUpdateAvailable = false;
|
||||
console.warn('Auto-update failed. Manual download required.');
|
||||
}
|
||||
|
||||
// Handle missing metadata files (platform-specific builds)
|
||||
if (err.code === 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND') {
|
||||
const platform = process.platform === 'darwin' ? 'macOS' :
|
||||
process.platform === 'win32' ? 'Windows' : 'Linux';
|
||||
const missingFile = process.platform === 'darwin' ? 'latest-mac.yml' :
|
||||
process.platform === 'win32' ? 'latest.yml' : 'latest-linux.yml';
|
||||
console.warn(`${platform} update metadata file (${missingFile}) not found in release.`);
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-error', {
|
||||
message: `Update metadata file for ${platform} not found in release. Please download manually.`,
|
||||
code: err.code,
|
||||
requiresManualDownload: true,
|
||||
updateVersion: this.updateVersion,
|
||||
isMissingMetadata: true
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Linux-specific: Handle installation permission errors
|
||||
if (process.platform === 'linux') {
|
||||
const errorMessage = err.message?.toLowerCase() || '';
|
||||
const errorStack = err.stack?.toLowerCase() || '';
|
||||
const isInstallError = errorMessage.includes('pkexec') ||
|
||||
errorMessage.includes('gksudo') ||
|
||||
errorMessage.includes('kdesudo') ||
|
||||
errorMessage.includes('setuid root') ||
|
||||
errorMessage.includes('exited with code 127') ||
|
||||
errorStack.includes('pacmanupdater') ||
|
||||
errorStack.includes('doinstall') ||
|
||||
errorMessage.includes('installation failed');
|
||||
|
||||
if (isInstallError) {
|
||||
console.warn('Linux installation error: Package installation requires root privileges. Manual installation required.');
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-error', {
|
||||
message: 'Auto-installation requires root privileges. Please download and install the update manually.',
|
||||
code: err.code || 'ERR_LINUX_INSTALL_PERMISSION',
|
||||
isLinuxInstallError: true,
|
||||
requiresManualDownload: true,
|
||||
updateVersion: this.updateVersion
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// macOS-specific: Handle unsigned app errors gracefully
|
||||
if (process.platform === 'darwin' && err.code === 2) {
|
||||
console.warn('macOS update error: App may not be code-signed. Auto-update requires code signing.');
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-error', {
|
||||
message: 'Auto-update requires code signing. Please download manually from GitHub.',
|
||||
code: err.code,
|
||||
isMacSigningError: true,
|
||||
requiresManualDownload: true,
|
||||
updateVersion: this.updateVersion
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-error', {
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
requiresManualDownload: isCriticalError,
|
||||
updateVersion: this.updateVersion
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progressObj) => {
|
||||
const message = `Download speed: ${progressObj.bytesPerSecond} - Downloaded ${progressObj.percent}% (${progressObj.transferred}/${progressObj.total})`;
|
||||
console.log(message);
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-download-progress', {
|
||||
percent: progressObj.percent,
|
||||
bytesPerSecond: progressObj.bytesPerSecond,
|
||||
transferred: progressObj.transferred,
|
||||
total: progressObj.total
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
console.log('Update downloaded:', info.version);
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-downloaded', {
|
||||
version: info.version,
|
||||
releaseName: info.releaseName,
|
||||
releaseNotes: info.releaseNotes
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdatesAndNotify() {
|
||||
// Check for updates and notify if available
|
||||
autoUpdater.checkForUpdatesAndNotify().catch(err => {
|
||||
console.error('Failed to check for updates:', err);
|
||||
|
||||
// Network errors are not critical - just log and continue
|
||||
const errorMessage = err.message?.toLowerCase() || '';
|
||||
const isNetworkError = errorMessage.includes('err_name_not_resolved') ||
|
||||
errorMessage.includes('network') ||
|
||||
errorMessage.includes('connection') ||
|
||||
errorMessage.includes('timeout') ||
|
||||
errorMessage.includes('enotfound');
|
||||
|
||||
if (isNetworkError) {
|
||||
console.warn('Network error checking for updates - will retry later. This is not critical.');
|
||||
return; // Don't show error UI for network issues
|
||||
}
|
||||
|
||||
const isCritical = this.isCriticalUpdateError(err);
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed() && isCritical) {
|
||||
this.mainWindow.webContents.send('update-error', {
|
||||
message: err.message || 'Failed to check for updates',
|
||||
code: err.code,
|
||||
requiresManualDownload: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdates() {
|
||||
// Manual check for updates (returns promise)
|
||||
return autoUpdater.checkForUpdates().catch(err => {
|
||||
console.error('Failed to check for updates:', err);
|
||||
|
||||
// Network errors are not critical - just return no update available
|
||||
const errorMessage = err.message?.toLowerCase() || '';
|
||||
const isNetworkError = errorMessage.includes('err_name_not_resolved') ||
|
||||
errorMessage.includes('network') ||
|
||||
errorMessage.includes('connection') ||
|
||||
errorMessage.includes('timeout') ||
|
||||
errorMessage.includes('enotfound');
|
||||
|
||||
if (isNetworkError) {
|
||||
console.warn('Network error - update check unavailable');
|
||||
return { updateInfo: null }; // Return empty result for network errors
|
||||
}
|
||||
|
||||
const isCritical = this.isCriticalUpdateError(err);
|
||||
if (isCritical) {
|
||||
this.autoUpdateAvailable = false;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
quitAndInstall() {
|
||||
// Quit and install the update
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
}
|
||||
|
||||
getUpdateInfo() {
|
||||
return {
|
||||
currentVersion: app.getVersion(),
|
||||
updateAvailable: false
|
||||
};
|
||||
}
|
||||
|
||||
clearUpdateCache() {
|
||||
try {
|
||||
// Get the cache directory based on platform
|
||||
const cacheDir = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Caches', `${app.getName()}-updater`)
|
||||
: process.platform === 'win32'
|
||||
? path.join(os.homedir(), 'AppData', 'Local', `${app.getName()}-updater`)
|
||||
: path.join(os.homedir(), '.cache', `${app.getName()}-updater`);
|
||||
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
console.log('Update cache cleared successfully');
|
||||
} else {
|
||||
console.log('Update cache directory does not exist');
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.warn('Could not clear update cache:', cacheError.message);
|
||||
}
|
||||
}
|
||||
|
||||
isCriticalUpdateError(err) {
|
||||
// Check for errors that prevent auto-update
|
||||
const errorMessage = err.message?.toLowerCase() || '';
|
||||
const errorCode = err.code;
|
||||
|
||||
// Missing update metadata files (platform-specific)
|
||||
if (errorCode === 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND' ||
|
||||
errorMessage.includes('cannot find latest') ||
|
||||
errorMessage.includes('latest-linux.yml') ||
|
||||
errorMessage.includes('latest-mac.yml') ||
|
||||
errorMessage.includes('latest.yml')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// macOS code signing errors
|
||||
if (process.platform === 'darwin' && (errorCode === 2 || errorMessage.includes('shipit'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Download failures
|
||||
if (errorMessage.includes('download') && errorMessage.includes('fail')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Network errors that prevent download (but we handle these separately as non-critical)
|
||||
// Installation errors
|
||||
if (errorMessage.includes('install') && errorMessage.includes('fail')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Permission errors
|
||||
if (errorMessage.includes('permission') || errorMessage.includes('access denied')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Linux installation errors (pkexec, sudo issues)
|
||||
if (process.platform === 'linux' && (
|
||||
errorMessage.includes('pkexec') ||
|
||||
errorMessage.includes('setuid root') ||
|
||||
errorMessage.includes('exited with code 127') ||
|
||||
errorMessage.includes('gksudo') ||
|
||||
errorMessage.includes('kdesudo'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// File system errors (but not "not found" for metadata files - handled above)
|
||||
if (errorMessage.includes('enoent') || errorMessage.includes('cannot find')) {
|
||||
// Only if it's not about metadata files
|
||||
if (!errorMessage.includes('latest') && !errorMessage.includes('.yml')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Generic critical error codes (but not checksum errors - those are handled separately)
|
||||
if (errorCode && (errorCode >= 100 ||
|
||||
errorCode === 'ERR_UPDATER_INVALID_RELEASE_FEED' ||
|
||||
errorCode === 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND')) {
|
||||
// Don't treat checksum errors as critical - they're handled separately
|
||||
if (errorCode === 'ERR_CHECKSUM_MISMATCH') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AppUpdater;
|
||||
347
backend/core/config.js
Normal file
347
backend/core/config.js
Normal file
@@ -0,0 +1,347 @@
|
||||
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.activeProfileId && config.profiles && config.profiles[config.activeProfileId]) {
|
||||
// Allow profile to override auth domain if ever needed
|
||||
// but for now stick to global or env
|
||||
}
|
||||
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();
|
||||
|
||||
// Prefer Active Profile's Java Path
|
||||
if (config.activeProfileId && config.profiles && config.profiles[config.activeProfileId]) {
|
||||
const profile = config.profiles[config.activeProfileId];
|
||||
if (profile.javaPath && profile.javaPath.trim().length > 0) {
|
||||
return profile.javaPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to global setting
|
||||
return config.javaPath || '';
|
||||
}
|
||||
|
||||
function saveInstallPath(installPath) {
|
||||
const trimmed = (installPath || '').trim();
|
||||
saveConfig({ installPath: trimmed });
|
||||
}
|
||||
|
||||
function loadInstallPath() {
|
||||
const config = loadConfig();
|
||||
return config.installPath || '';
|
||||
}
|
||||
|
||||
function saveDiscordRPC(enabled) {
|
||||
saveConfig({ discordRPC: !!enabled });
|
||||
}
|
||||
|
||||
function loadDiscordRPC() {
|
||||
const config = loadConfig();
|
||||
return config.discordRPC !== undefined ? config.discordRPC : true;
|
||||
}
|
||||
|
||||
function saveLanguage(language) {
|
||||
saveConfig({ language: language || 'en' });
|
||||
}
|
||||
|
||||
function loadLanguage() {
|
||||
const config = loadConfig();
|
||||
return config.language || 'en';
|
||||
}
|
||||
|
||||
function saveCloseLauncherOnStart(enabled) {
|
||||
saveConfig({ closeLauncherOnStart: !!enabled });
|
||||
}
|
||||
|
||||
function loadCloseLauncherOnStart() {
|
||||
const config = loadConfig();
|
||||
return config.closeLauncherOnStart !== undefined ? config.closeLauncherOnStart : false;
|
||||
}
|
||||
|
||||
function saveModsToConfig(mods) {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
|
||||
// Config migration handles structure, but mod saves must go to the ACTIVE profile.
|
||||
// Global installedMods is kept mainly for reference/migration.
|
||||
// The profile is the source of truth for enabled mods.
|
||||
|
||||
|
||||
if (config.activeProfileId && config.profiles && config.profiles[config.activeProfileId]) {
|
||||
config.profiles[config.activeProfileId].mods = mods;
|
||||
} else {
|
||||
// Fallback for legacy or no-profile state
|
||||
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();
|
||||
|
||||
// Prefer Active Profile
|
||||
if (config.activeProfileId && config.profiles && config.profiles[config.activeProfileId]) {
|
||||
return config.profiles[config.activeProfileId].mods || [];
|
||||
}
|
||||
|
||||
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() });
|
||||
}
|
||||
|
||||
// UUID Management Functions
|
||||
function getCurrentUuid() {
|
||||
const username = loadUsername();
|
||||
return getUuidForUser(username);
|
||||
}
|
||||
|
||||
function getAllUuidMappings() {
|
||||
const config = loadConfig();
|
||||
return config.userUuids || {};
|
||||
}
|
||||
|
||||
function setUuidForUser(username, uuid) {
|
||||
const { v4: uuidv4, validate: validateUuid } = require('uuid');
|
||||
|
||||
// Validate UUID format
|
||||
if (!validateUuid(uuid)) {
|
||||
throw new Error('Invalid UUID format');
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const userUuids = config.userUuids || {};
|
||||
userUuids[username] = uuid;
|
||||
saveConfig({ userUuids });
|
||||
|
||||
return uuid;
|
||||
}
|
||||
|
||||
function generateNewUuid() {
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
function deleteUuidForUser(username) {
|
||||
const config = loadConfig();
|
||||
const userUuids = config.userUuids || {};
|
||||
|
||||
if (userUuids[username]) {
|
||||
delete userUuids[username];
|
||||
saveConfig({ userUuids });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function resetCurrentUserUuid() {
|
||||
const username = loadUsername();
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const newUuid = uuidv4();
|
||||
|
||||
return setUuidForUser(username, newUuid);
|
||||
}
|
||||
|
||||
function saveChatColor(color) {
|
||||
const config = loadConfig();
|
||||
config.chatColor = color;
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
function loadChatColor() {
|
||||
const config = loadConfig();
|
||||
return config.chatColor || '#3498db';
|
||||
}
|
||||
|
||||
function saveGpuPreference(gpuPreference) {
|
||||
saveConfig({ gpuPreference: gpuPreference || 'auto' });
|
||||
}
|
||||
|
||||
function loadGpuPreference() {
|
||||
const config = loadConfig();
|
||||
return config.gpuPreference || 'auto';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
saveUsername,
|
||||
loadUsername,
|
||||
saveChatUsername,
|
||||
loadChatUsername,
|
||||
saveChatColor,
|
||||
loadChatColor,
|
||||
getUuidForUser,
|
||||
saveJavaPath,
|
||||
loadJavaPath,
|
||||
saveInstallPath,
|
||||
loadInstallPath,
|
||||
saveDiscordRPC,
|
||||
loadDiscordRPC,
|
||||
saveLanguage,
|
||||
loadLanguage,
|
||||
saveModsToConfig,
|
||||
loadModsFromConfig,
|
||||
isFirstLaunch,
|
||||
markAsLaunched,
|
||||
CONFIG_FILE,
|
||||
// Auth server exports
|
||||
getAuthServerUrl,
|
||||
getAuthDomain,
|
||||
saveAuthDomain,
|
||||
// UUID Management exports
|
||||
getCurrentUuid,
|
||||
getAllUuidMappings,
|
||||
setUuidForUser,
|
||||
generateNewUuid,
|
||||
deleteUuidForUser,
|
||||
resetCurrentUserUuid,
|
||||
// GPU Preference exports
|
||||
saveGpuPreference,
|
||||
loadGpuPreference,
|
||||
// Close Launcher export
|
||||
saveCloseLauncherOnStart,
|
||||
loadCloseLauncherOnStart
|
||||
};
|
||||
229
backend/core/paths.js
Normal file
229
backend/core/paths.js
Normal file
@@ -0,0 +1,229 @@
|
||||
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) {
|
||||
// Use the standard app directory logic which handles platforms correctly
|
||||
installPath = getAppDir();
|
||||
}
|
||||
|
||||
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');
|
||||
const profilesPath = path.join(userDataPath, 'Profiles');
|
||||
|
||||
if (!fs.existsSync(modsPath)) {
|
||||
// Ensure the Mods directory exists
|
||||
fs.mkdirSync(modsPath, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(disabledModsPath)) {
|
||||
fs.mkdirSync(disabledModsPath, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(profilesPath)) {
|
||||
fs.mkdirSync(profilesPath, { recursive: true });
|
||||
}
|
||||
|
||||
return modsPath;
|
||||
} catch (error) {
|
||||
console.error('Error getting mods path:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getProfilesDir(customInstallPath = null) {
|
||||
try {
|
||||
// get UserData path
|
||||
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) installPath = getAppDir();
|
||||
|
||||
const gameLatest = path.join(installPath, 'release', 'package', 'game', 'latest');
|
||||
const userDataPath = findUserDataPath(gameLatest);
|
||||
const profilesDir = path.join(userDataPath, 'Profiles');
|
||||
|
||||
if (!fs.existsSync(profilesDir)) {
|
||||
fs.mkdirSync(profilesDir, { recursive: true });
|
||||
}
|
||||
|
||||
return profilesDir;
|
||||
} catch (err) {
|
||||
console.error('Error getting profiles dir:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAppDir,
|
||||
getResolvedAppDir,
|
||||
expandHome,
|
||||
APP_DIR,
|
||||
CACHE_DIR,
|
||||
TOOLS_DIR,
|
||||
GAME_DIR,
|
||||
JRE_DIR,
|
||||
PLAYER_ID_FILE,
|
||||
getClientCandidates,
|
||||
findClientPath,
|
||||
findUserDataPath,
|
||||
findUserDataRecursive,
|
||||
getModsPath,
|
||||
getProfilesDir
|
||||
};
|
||||
2338
backend/launcher.js
2338
backend/launcher.js
File diff suppressed because it is too large
Load Diff
213
backend/logger.js
Normal file
213
backend/logger.js
Normal file
@@ -0,0 +1,213 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
class Logger {
|
||||
constructor() {
|
||||
this.logDir = null;
|
||||
this.logFile = null;
|
||||
this.maxLogSize = 10 * 1024 * 1024; // 10MB
|
||||
this.maxLogFiles = 5;
|
||||
this.originalConsole = {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
info: console.info
|
||||
};
|
||||
|
||||
this.initializeLogDirectory();
|
||||
}
|
||||
|
||||
getAppDir() {
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(home, 'AppData', 'Local', 'HytaleF2P');
|
||||
} else if (process.platform === 'darwin') {
|
||||
return path.join(home, 'Library', 'Application Support', 'HytaleF2P');
|
||||
} else {
|
||||
return path.join(home, '.hytalef2p');
|
||||
}
|
||||
}
|
||||
|
||||
getInstallPath() {
|
||||
try {
|
||||
const configFile = path.join(this.getAppDir(), 'config.json');
|
||||
if (fs.existsSync(configFile)) {
|
||||
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||
if (config.installPath && config.installPath.trim()) {
|
||||
return path.join(config.installPath.trim(), 'HytaleF2P');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
return this.getAppDir();
|
||||
}
|
||||
|
||||
initializeLogDirectory() {
|
||||
try {
|
||||
const installPath = this.getInstallPath();
|
||||
this.logDir = path.join(installPath, 'logs');
|
||||
|
||||
if (!fs.existsSync(this.logDir)) {
|
||||
fs.mkdirSync(this.logDir, { recursive: true });
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const dateString = today.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const timeString = today.toISOString().split('T')[1].split('.')[0].replace(/:/g, '-'); // HH-MM-SS
|
||||
this.logFile = path.join(this.logDir, `launcher-${dateString}-${timeString}.log`);
|
||||
|
||||
this.writeToFile(`\n=== NEW LAUNCHER SESSION - ${today.toISOString()} ===\n`);
|
||||
|
||||
} catch (error) {
|
||||
this.logDir = path.join(os.tmpdir(), 'HytaleF2P-logs');
|
||||
if (!fs.existsSync(this.logDir)) {
|
||||
fs.mkdirSync(this.logDir, { recursive: true });
|
||||
}
|
||||
const today = new Date();
|
||||
const dateString = today.toISOString().split('T')[0];
|
||||
const timeString = today.toISOString().split('T')[1].split('.')[0].replace(/:/g, '-');
|
||||
this.logFile = path.join(this.logDir, `launcher-${dateString}-${timeString}.log`);
|
||||
this.writeToFile(`\n=== FALLBACK SESSION IN TEMP - ${today.toISOString()} ===\n`);
|
||||
}
|
||||
}
|
||||
|
||||
writeToFile(message) {
|
||||
if (!this.logFile) return;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(this.logFile)) {
|
||||
const stats = fs.statSync(this.logFile);
|
||||
if (stats.size > this.maxLogSize) {
|
||||
this.rotateLogFile();
|
||||
}
|
||||
}
|
||||
|
||||
fs.appendFileSync(this.logFile, message, 'utf8');
|
||||
} catch (error) {
|
||||
this.originalConsole.error('Impossible d\'écrire dans le fichier de log:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
rotateLogFile() {
|
||||
try {
|
||||
const today = new Date();
|
||||
const dateString = today.toISOString().split('T')[0];
|
||||
const timeString = today.toISOString().split('T')[1].split('.')[0].replace(/:/g, '-');
|
||||
|
||||
const rotatedFile = path.join(this.logDir, `launcher-${dateString}-${timeString}.log`);
|
||||
fs.renameSync(this.logFile, rotatedFile);
|
||||
|
||||
this.cleanupOldLogs();
|
||||
|
||||
const newToday = new Date();
|
||||
const newDateString = newToday.toISOString().split('T')[0];
|
||||
const newTimeString = newToday.toISOString().split('T')[1].split('.')[0].replace(/:/g, '-');
|
||||
this.logFile = path.join(this.logDir, `launcher-${newDateString}-${newTimeString}.log`);
|
||||
this.writeToFile(`\n=== LOG ROTATION - ${newToday.toISOString()} ===\n`);
|
||||
|
||||
} catch (error) {
|
||||
this.originalConsole.error('Erreur lors de la rotation des logs:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
cleanupOldLogs() {
|
||||
try {
|
||||
const files = fs.readdirSync(this.logDir)
|
||||
.filter(file => file.startsWith('launcher-') && file.endsWith('.log'))
|
||||
.map(file => ({
|
||||
name: file,
|
||||
path: path.join(this.logDir, file),
|
||||
mtime: fs.statSync(path.join(this.logDir, file)).mtime
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
if (files.length > this.maxLogFiles) {
|
||||
const filesToDelete = files.slice(this.maxLogFiles);
|
||||
filesToDelete.forEach(file => {
|
||||
try {
|
||||
fs.unlinkSync(file.path);
|
||||
} catch (err) {
|
||||
this.originalConsole.error(`Impossible de supprimer le fichier de log ${file.name}:`, err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.originalConsole.error('Erreur lors du nettoyage des logs:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
formatLogMessage(level, ...args) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const message = args.map(arg => {
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.stringify(arg, null, 2);
|
||||
} catch (e) {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
}).join(' ');
|
||||
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
const logMessage = this.formatLogMessage('info', ...args);
|
||||
this.writeToFile(logMessage);
|
||||
this.originalConsole.log(...args);
|
||||
}
|
||||
|
||||
error(...args) {
|
||||
const logMessage = this.formatLogMessage('error', ...args);
|
||||
this.writeToFile(logMessage);
|
||||
this.originalConsole.error(...args);
|
||||
}
|
||||
|
||||
warn(...args) {
|
||||
const logMessage = this.formatLogMessage('warn', ...args);
|
||||
this.writeToFile(logMessage);
|
||||
this.originalConsole.warn(...args);
|
||||
}
|
||||
|
||||
info(...args) {
|
||||
const logMessage = this.formatLogMessage('info', ...args);
|
||||
this.writeToFile(logMessage);
|
||||
this.originalConsole.info(...args);
|
||||
}
|
||||
|
||||
interceptConsole() {
|
||||
console.log = (...args) => this.log(...args);
|
||||
console.error = (...args) => this.error(...args);
|
||||
console.warn = (...args) => this.warn(...args);
|
||||
console.info = (...args) => this.info(...args);
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
this.error('Uncaught exception:', error.stack || error.message);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
this.error('Unhandled rejection at', promise, 'reason:', reason);
|
||||
});
|
||||
}
|
||||
|
||||
restoreConsole() {
|
||||
console.log = this.originalConsole.log;
|
||||
console.error = this.originalConsole.error;
|
||||
console.warn = this.originalConsole.warn;
|
||||
console.info = this.originalConsole.info;
|
||||
}
|
||||
|
||||
getLogDirectory() {
|
||||
return this.logDir;
|
||||
}
|
||||
|
||||
updateInstallPath() {
|
||||
this.initializeLogDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
const logger = new Logger();
|
||||
|
||||
module.exports = logger;
|
||||
75
backend/managers/butlerManager.js
Normal file
75
backend/managers/butlerManager.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const AdmZip = require('adm-zip');
|
||||
const { TOOLS_DIR } = require('../core/paths');
|
||||
const { getOS, getArch } = require('../utils/platformUtils');
|
||||
const { downloadFile } = require('../utils/fileManager');
|
||||
|
||||
async function installButler(toolsDir = TOOLS_DIR) {
|
||||
if (!fs.existsSync(toolsDir)) {
|
||||
fs.mkdirSync(toolsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const butlerName = process.platform === 'win32' ? 'butler.exe' : 'butler';
|
||||
const butlerPath = path.join(toolsDir, butlerName);
|
||||
const zipPath = path.join(toolsDir, 'butler.zip');
|
||||
|
||||
if (fs.existsSync(butlerPath)) {
|
||||
return butlerPath;
|
||||
}
|
||||
|
||||
let urls = [];
|
||||
const osName = getOS();
|
||||
const arch = getArch();
|
||||
if (osName === 'windows') {
|
||||
urls = ['https://broth.itch.zone/butler/windows-amd64/LATEST/archive/default'];
|
||||
} else if (osName === 'darwin') {
|
||||
if (arch === 'arm64') {
|
||||
urls = [
|
||||
'https://broth.itch.zone/butler/darwin-arm64/LATEST/archive/default',
|
||||
'https://broth.itch.zone/butler/darwin-amd64/LATEST/archive/default'
|
||||
];
|
||||
} else {
|
||||
urls = ['https://broth.itch.zone/butler/darwin-amd64/LATEST/archive/default'];
|
||||
}
|
||||
} else if (osName === 'linux') {
|
||||
urls = ['https://broth.itch.zone/butler/linux-amd64/LATEST/archive/default'];
|
||||
} else {
|
||||
throw new Error('Operating system not supported');
|
||||
}
|
||||
|
||||
console.log('Fetching Butler tool...');
|
||||
let lastError = null;
|
||||
for (const url of urls) {
|
||||
try {
|
||||
await downloadFile(url, zipPath);
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
console.log('Unpacking Butler...');
|
||||
const zip = new AdmZip(zipPath);
|
||||
zip.extractAllTo(toolsDir, true);
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(butlerPath, 0o755);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(zipPath);
|
||||
} catch (err) {
|
||||
console.log('Notice: could not delete butler.zip');
|
||||
}
|
||||
|
||||
return butlerPath;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
installButler
|
||||
};
|
||||
422
backend/managers/gameLauncher.js
Normal file
422
backend/managers/gameLauncher.js
Normal file
@@ -0,0 +1,422 @@
|
||||
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, setupGpuEnvironment } = 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');
|
||||
const { syncModsForCurrentProfile } = require('./modManager');
|
||||
|
||||
// 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, gpuPreference = 'auto') {
|
||||
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);
|
||||
}
|
||||
|
||||
// Ensure mods are synced for the active profile before launching
|
||||
try {
|
||||
console.log('Syncing mods for active profile before launch...');
|
||||
if (progressCallback) progressCallback('Syncing mods...', null, null, null, null);
|
||||
await syncModsForCurrentProfile();
|
||||
} catch (syncError) {
|
||||
console.error('Failed to sync mods before launch:', syncError);
|
||||
// Continue anyway? Or fail?
|
||||
// Warn user but continue might be safer to avoid blocking play if sync is just glitchy
|
||||
}
|
||||
|
||||
console.log('Starting game...');
|
||||
console.log(`Command: "${clientPath}" ${args.join(' ')}`);
|
||||
|
||||
const env = { ...process.env };
|
||||
|
||||
const waylandEnv = setupWaylandEnvironment();
|
||||
Object.assign(env, waylandEnv);
|
||||
|
||||
const gpuEnv = setupGpuEnvironment(gpuPreference);
|
||||
Object.assign(env, gpuEnv);
|
||||
|
||||
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, gpuPreference = 'auto') {
|
||||
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, gpuPreference);
|
||||
} 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
|
||||
};
|
||||
498
backend/managers/gameManager.js
Normal file
498
backend/managers/gameManager.js
Normal file
@@ -0,0 +1,498 @@
|
||||
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 { 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 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 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
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function repairGame(progressCallback) {
|
||||
const appDir = getResolvedAppDir();
|
||||
const gameDir = path.join(appDir, 'release', 'package', 'game', 'latest');
|
||||
|
||||
// Check if game exists
|
||||
if (!fs.existsSync(gameDir)) {
|
||||
throw new Error('Game directory not found. Cannot repair.');
|
||||
}
|
||||
|
||||
// Locate UserData
|
||||
const userDataPath = findUserDataRecursive(gameDir);
|
||||
let userDataBackup = null;
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Backing up user data...', 10, null, null, null);
|
||||
}
|
||||
|
||||
// Backup UserData
|
||||
if (userDataPath && fs.existsSync(userDataPath)) {
|
||||
userDataBackup = path.join(appDir, 'UserData_backup_repair_' + Date.now());
|
||||
console.log(`Backing up UserData during repair from ${userDataPath} to ${userDataBackup}`);
|
||||
|
||||
// Copy function
|
||||
function copyRecursive(src, dest) {
|
||||
const stat = fs.statSync(src);
|
||||
if (stat.isDirectory()) {
|
||||
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
||||
fs.readdirSync(src).forEach(child => copyRecursive(path.join(src, child), path.join(dest, child)));
|
||||
} else {
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
}
|
||||
|
||||
copyRecursive(userDataPath, userDataBackup);
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Removing old game files...', 30, null, null, null);
|
||||
}
|
||||
|
||||
// Delete Game and Cache Directory
|
||||
console.log('Removing corrupted game files...');
|
||||
fs.rmSync(gameDir, { recursive: true, force: true });
|
||||
|
||||
const cacheDir = path.join(appDir, 'cache');
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
console.log('Clearing cache directory...');
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('Reinstalling game files...');
|
||||
|
||||
// Passing null/undefined for overrides to use defaults/saved configs
|
||||
// installGame calls progressCallback internally
|
||||
await installGame('Player', progressCallback);
|
||||
|
||||
// Restore UserData
|
||||
if (userDataBackup && fs.existsSync(userDataBackup)) {
|
||||
if (progressCallback) {
|
||||
progressCallback('Restoring user data...', 90, null, null, null);
|
||||
}
|
||||
|
||||
// installGame creates: path.join(customGameDir, 'Client', 'UserData')
|
||||
const newGameDir = path.join(appDir, 'release', 'package', 'game', 'latest');
|
||||
const newUserDataPath = path.join(newGameDir, 'Client', 'UserData');
|
||||
|
||||
if (!fs.existsSync(newUserDataPath)) {
|
||||
fs.mkdirSync(newUserDataPath, { 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 });
|
||||
fs.readdirSync(src).forEach(child => copyRecursive(path.join(src, child), path.join(dest, child)));
|
||||
} else {
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
}
|
||||
|
||||
copyRecursive(userDataBackup, newUserDataPath);
|
||||
|
||||
// Cleanup Backup
|
||||
console.log('Cleaning up repair backup...');
|
||||
fs.rmSync(userDataBackup, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Repair completed successfully!', 100, null, null, null);
|
||||
}
|
||||
|
||||
return { success: true, repaired: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
downloadPWR,
|
||||
applyPWR,
|
||||
updateGameFiles,
|
||||
isGameInstalled,
|
||||
installGame,
|
||||
uninstallGame,
|
||||
isGameInstalled,
|
||||
installGame,
|
||||
uninstallGame,
|
||||
checkExistingGameInstallation,
|
||||
repairGame
|
||||
};
|
||||
363
backend/managers/javaManager.js
Normal file
363
backend/managers/javaManager.js
Normal file
@@ -0,0 +1,363 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const axios = require('axios');
|
||||
const AdmZip = require('adm-zip');
|
||||
const crypto = require('crypto');
|
||||
const tar = require('tar');
|
||||
const { expandHome, JRE_DIR } = require('../core/paths');
|
||||
const { getOS, getArch } = require('../utils/platformUtils');
|
||||
const { loadConfig } = require('../core/config');
|
||||
const { downloadFile } = require('../utils/fileManager');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const JAVA_EXECUTABLE = 'java' + (process.platform === 'win32' ? '.exe' : '');
|
||||
|
||||
async function findJavaOnPath(commandName = 'java') {
|
||||
const lookupCmd = process.platform === 'win32' ? 'where' : 'which';
|
||||
try {
|
||||
const { stdout } = await execFileAsync(lookupCmd, [commandName]);
|
||||
const line = stdout.split(/\r?\n/).map(lineItem => lineItem.trim()).find(Boolean);
|
||||
return line || null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getMacJavaHome() {
|
||||
if (process.platform !== 'darwin') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/libexec/java_home');
|
||||
const home = stdout.trim();
|
||||
if (!home) {
|
||||
return null;
|
||||
}
|
||||
return path.join(home, 'bin', JAVA_EXECUTABLE);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveJavaPath(inputPath) {
|
||||
const trimmed = (inputPath || '').trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expanded = expandHome(trimmed);
|
||||
if (fs.existsSync(expanded)) {
|
||||
const stat = fs.statSync(expanded);
|
||||
if (stat.isDirectory()) {
|
||||
const candidate = path.join(expanded, 'bin', JAVA_EXECUTABLE);
|
||||
return fs.existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(expanded)) {
|
||||
return await findJavaOnPath(trimmed);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function detectSystemJava() {
|
||||
const envHome = process.env.JAVA_HOME;
|
||||
if (envHome) {
|
||||
const envJava = path.join(envHome, 'bin', JAVA_EXECUTABLE);
|
||||
if (fs.existsSync(envJava)) {
|
||||
return envJava;
|
||||
}
|
||||
}
|
||||
|
||||
const macJava = await getMacJavaHome();
|
||||
if (macJava && fs.existsSync(macJava)) {
|
||||
return macJava;
|
||||
}
|
||||
|
||||
const pathJava = await findJavaOnPath('java');
|
||||
if (pathJava && fs.existsSync(pathJava)) {
|
||||
return pathJava;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadJavaPath() {
|
||||
const config = loadConfig();
|
||||
return config.javaPath || '';
|
||||
}
|
||||
|
||||
function getBundledJavaPath(jreDir = JRE_DIR) {
|
||||
const candidates = [
|
||||
path.join(jreDir, 'bin', JAVA_EXECUTABLE)
|
||||
];
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
candidates.push(path.join(jreDir, 'Contents', 'Home', 'bin', JAVA_EXECUTABLE));
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getJavaExec(jreDir = JRE_DIR) {
|
||||
const bundledJava = getBundledJavaPath(jreDir);
|
||||
if (bundledJava) {
|
||||
return bundledJava;
|
||||
}
|
||||
|
||||
console.log('Notice: Java runtime not found, using system default');
|
||||
return 'java';
|
||||
}
|
||||
|
||||
async function getJavaDetection() {
|
||||
const candidates = [];
|
||||
const bundledJava = getBundledJavaPath() || path.join(JRE_DIR, 'bin', JAVA_EXECUTABLE);
|
||||
|
||||
candidates.push({
|
||||
label: 'Bundled JRE',
|
||||
path: bundledJava,
|
||||
exists: fs.existsSync(bundledJava)
|
||||
});
|
||||
|
||||
const javaHomeEnv = process.env.JAVA_HOME;
|
||||
if (javaHomeEnv) {
|
||||
const envJava = path.join(javaHomeEnv, 'bin', JAVA_EXECUTABLE);
|
||||
candidates.push({
|
||||
label: 'JAVA_HOME',
|
||||
path: envJava,
|
||||
exists: fs.existsSync(envJava),
|
||||
note: fs.existsSync(envJava) ? '' : 'Not found'
|
||||
});
|
||||
} else {
|
||||
candidates.push({
|
||||
label: 'JAVA_HOME',
|
||||
path: '',
|
||||
exists: false,
|
||||
note: 'Not set'
|
||||
});
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const macJava = await getMacJavaHome();
|
||||
if (macJava) {
|
||||
candidates.push({
|
||||
label: 'java_home',
|
||||
path: macJava,
|
||||
exists: fs.existsSync(macJava),
|
||||
note: fs.existsSync(macJava) ? '' : 'Not found'
|
||||
});
|
||||
} else {
|
||||
candidates.push({
|
||||
label: 'java_home',
|
||||
path: '',
|
||||
exists: false,
|
||||
note: 'Not found'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pathJava = await findJavaOnPath('java');
|
||||
if (pathJava) {
|
||||
candidates.push({
|
||||
label: 'PATH',
|
||||
path: pathJava,
|
||||
exists: true
|
||||
});
|
||||
} else {
|
||||
candidates.push({
|
||||
label: 'PATH',
|
||||
path: '',
|
||||
exists: false,
|
||||
note: 'java not found'
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
javaPath: loadJavaPath(),
|
||||
candidates
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadJRE(progressCallback, cacheDir, jreDir = JRE_DIR) {
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
|
||||
const osName = getOS();
|
||||
const arch = getArch();
|
||||
|
||||
const bundledJava = getBundledJavaPath(jreDir);
|
||||
if (bundledJava) {
|
||||
console.log('Java runtime found, skipping download');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Requesting Java runtime information...');
|
||||
const response = await axios.get('https://launcher.hytale.com/version/release/jre.json', {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
}
|
||||
});
|
||||
const jreData = response.data;
|
||||
|
||||
const osData = jreData.download_url[osName];
|
||||
if (!osData) {
|
||||
throw new Error(`Java runtime unavailable for platform: ${osName}`);
|
||||
}
|
||||
|
||||
const platform = osData[arch];
|
||||
if (!platform) {
|
||||
throw new Error(`Java runtime unavailable for architecture ${arch} on ${osName}`);
|
||||
}
|
||||
|
||||
const fileName = path.basename(platform.url);
|
||||
const cacheFile = path.join(cacheDir, fileName);
|
||||
|
||||
if (!fs.existsSync(cacheFile)) {
|
||||
if (progressCallback) {
|
||||
progressCallback('Fetching Java runtime...', null, null, null, null);
|
||||
}
|
||||
console.log('Fetching Java runtime...');
|
||||
await downloadFile(platform.url, cacheFile, progressCallback);
|
||||
console.log('Download finished');
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Validating files...', null, null, null, null);
|
||||
}
|
||||
console.log('Validating files...');
|
||||
const fileBuffer = fs.readFileSync(cacheFile);
|
||||
const hashSum = crypto.createHash('sha256');
|
||||
hashSum.update(fileBuffer);
|
||||
const hex = hashSum.digest('hex');
|
||||
|
||||
if (hex !== platform.sha256) {
|
||||
fs.unlinkSync(cacheFile);
|
||||
throw new Error(`File validation failed: expected ${platform.sha256} but got ${hex}`);
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Unpacking Java runtime...', null, null, null, null);
|
||||
}
|
||||
console.log('Unpacking Java runtime...');
|
||||
await extractJRE(cacheFile, jreDir);
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
const javaCandidates = [
|
||||
path.join(jreDir, 'bin', JAVA_EXECUTABLE),
|
||||
path.join(jreDir, 'Contents', 'Home', 'bin', JAVA_EXECUTABLE)
|
||||
];
|
||||
for (const javaPath of javaCandidates) {
|
||||
if (fs.existsSync(javaPath)) {
|
||||
fs.chmodSync(javaPath, 0o755);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flattenJREDir(jreDir);
|
||||
|
||||
try {
|
||||
fs.unlinkSync(cacheFile);
|
||||
} catch (err) {
|
||||
console.log('Notice: could not delete cached Java files:', err.message);
|
||||
}
|
||||
|
||||
console.log('Java runtime ready');
|
||||
}
|
||||
|
||||
async function extractJRE(archivePath, destDir) {
|
||||
if (fs.existsSync(destDir)) {
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
if (archivePath.endsWith('.zip')) {
|
||||
return extractZip(archivePath, destDir);
|
||||
} else if (archivePath.endsWith('.tar.gz')) {
|
||||
return extractTarGz(archivePath, destDir);
|
||||
} else {
|
||||
throw new Error(`Archive type not supported: ${archivePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractZip(zipPath, dest) {
|
||||
const zip = new AdmZip(zipPath);
|
||||
const entries = zip.getEntries();
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dest, entry.entryName);
|
||||
|
||||
const resolvedPath = path.resolve(entryPath);
|
||||
const resolvedDest = path.resolve(dest);
|
||||
if (!resolvedPath.startsWith(resolvedDest)) {
|
||||
throw new Error(`Invalid file path detected: ${entryPath}`);
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
fs.mkdirSync(entryPath, { recursive: true });
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(entryPath), { recursive: true });
|
||||
fs.writeFileSync(entryPath, entry.getData());
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(entryPath, entry.header.attr >>> 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractTarGz(tarGzPath, dest) {
|
||||
return tar.extract({
|
||||
file: tarGzPath,
|
||||
cwd: dest,
|
||||
strip: 0
|
||||
});
|
||||
}
|
||||
|
||||
function flattenJREDir(jreLatest) {
|
||||
try {
|
||||
const entries = fs.readdirSync(jreLatest, { withFileTypes: true });
|
||||
|
||||
if (entries.length !== 1 || !entries[0].isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nested = path.join(jreLatest, entries[0].name);
|
||||
const files = fs.readdirSync(nested, { withFileTypes: true });
|
||||
|
||||
for (const file of files) {
|
||||
const oldPath = path.join(nested, file.name);
|
||||
const newPath = path.join(jreLatest, file.name);
|
||||
fs.renameSync(oldPath, newPath);
|
||||
}
|
||||
|
||||
fs.rmSync(nested, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.log('Notice: could not restructure Java directory:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findJavaOnPath,
|
||||
getMacJavaHome,
|
||||
resolveJavaPath,
|
||||
detectSystemJava,
|
||||
loadJavaPath,
|
||||
getBundledJavaPath,
|
||||
getJavaExec,
|
||||
getJavaDetection,
|
||||
downloadJRE,
|
||||
extractJRE,
|
||||
JAVA_EXECUTABLE
|
||||
};
|
||||
484
backend/managers/modManager.js
Normal file
484
backend/managers/modManager.js
Normal file
@@ -0,0 +1,484 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const axios = require('axios');
|
||||
const { getModsPath, getProfilesDir } = require('../core/paths');
|
||||
const { saveModsToConfig, loadModsFromConfig } = require('../core/config');
|
||||
const profileManager = require('./profileManager');
|
||||
|
||||
const API_KEY = process.env.CURSEFORGE_API_KEY;
|
||||
|
||||
/**
|
||||
* Get the physical mods path for a specific profile.
|
||||
* Each profile now has its own 'mods' folder.
|
||||
*/
|
||||
function getProfileModsPath(profileId) {
|
||||
const profilesDir = getProfilesDir();
|
||||
if (!profilesDir) return null;
|
||||
|
||||
const profileDir = path.join(profilesDir, profileId);
|
||||
const modsDir = path.join(profileDir, 'mods');
|
||||
|
||||
if (!fs.existsSync(modsDir)) {
|
||||
fs.mkdirSync(modsDir, { recursive: true });
|
||||
}
|
||||
|
||||
return modsDir;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Helper to get mods from active profile
|
||||
function getProfileMods() {
|
||||
const profile = profileManager.getActiveProfile();
|
||||
return profile ? (profile.mods || []) : [];
|
||||
}
|
||||
|
||||
async function loadInstalledMods(modsPath) {
|
||||
try {
|
||||
// Sync first to ensure we detect any manually added mods and paths are correct
|
||||
await syncModsForCurrentProfile();
|
||||
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
if (!activeProfile) return [];
|
||||
|
||||
const profileMods = activeProfile.mods || [];
|
||||
|
||||
// Use profile-specific paths
|
||||
const profileModsPath = getProfileModsPath(activeProfile.id);
|
||||
const profileDisabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods');
|
||||
|
||||
if (!fs.existsSync(profileModsPath)) fs.mkdirSync(profileModsPath, { recursive: true });
|
||||
if (!fs.existsSync(profileDisabledModsPath)) fs.mkdirSync(profileDisabledModsPath, { recursive: true });
|
||||
|
||||
const validMods = [];
|
||||
|
||||
for (const modConfig of profileMods) {
|
||||
// Check if file exists in either location
|
||||
const inEnabled = fs.existsSync(path.join(profileModsPath, modConfig.fileName));
|
||||
const inDisabled = fs.existsSync(path.join(profileDisabledModsPath, modConfig.fileName));
|
||||
|
||||
if (inEnabled || inDisabled) {
|
||||
validMods.push({
|
||||
...modConfig,
|
||||
// Set filePath based on physical location
|
||||
filePath: inEnabled ? path.join(profileModsPath, modConfig.fileName) : path.join(profileDisabledModsPath, modConfig.fileName),
|
||||
enabled: modConfig.enabled !== false // Default true
|
||||
});
|
||||
} else {
|
||||
console.warn(`[ModManager] Mod ${modConfig.fileName} listed in profile but not found on disk.`);
|
||||
// Include it so user can see it's missing or remove it
|
||||
validMods.push({
|
||||
...modConfig,
|
||||
filePath: null,
|
||||
missing: true,
|
||||
enabled: modConfig.enabled !== false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return validMods;
|
||||
} catch (error) {
|
||||
console.error('Error loading installed mods:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadMod(modInfo) {
|
||||
try {
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
if (!activeProfile) throw new Error('No active profile to save mod to');
|
||||
|
||||
const modsPath = getProfileModsPath(activeProfile.id);
|
||||
if (!modsPath) throw new Error('Could not determine profile mods path');
|
||||
|
||||
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 || modInfo.curseForgeId}/files/${modInfo.fileId || modInfo.curseForgeFileId}`, {
|
||||
headers: {
|
||||
'x-api-key': modInfo.apiKey || API_KEY,
|
||||
'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', () => {
|
||||
// Update Active Profile
|
||||
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
|
||||
};
|
||||
|
||||
const updatedMods = [...(activeProfile.mods || []), newMod];
|
||||
profileManager.updateProfile(activeProfile.id, { mods: updatedMods });
|
||||
|
||||
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 activeProfile = profileManager.getActiveProfile();
|
||||
if (!activeProfile) throw new Error('No active profile');
|
||||
|
||||
const profileMods = activeProfile.mods || [];
|
||||
const mod = profileMods.find(m => m.id === modId);
|
||||
|
||||
if (!mod) {
|
||||
throw new Error('Mod not found in profile');
|
||||
}
|
||||
|
||||
// Use profile paths
|
||||
const profileModsPath = getProfileModsPath(activeProfile.id);
|
||||
const disabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods');
|
||||
|
||||
const enabledPath = path.join(profileModsPath, mod.fileName);
|
||||
const disabledPath = path.join(disabledModsPath, mod.fileName);
|
||||
|
||||
let fileRemoved = false;
|
||||
// Try to remove file from both locations to be safe
|
||||
if (fs.existsSync(enabledPath)) {
|
||||
fs.unlinkSync(enabledPath);
|
||||
fileRemoved = true;
|
||||
}
|
||||
if (fs.existsSync(disabledPath)) {
|
||||
try { fs.unlinkSync(disabledPath); fileRemoved = true; } catch (e) { }
|
||||
}
|
||||
|
||||
if (!fileRemoved) {
|
||||
console.warn('Mod file not found on filesystem, removing from profile anyway');
|
||||
}
|
||||
|
||||
const updatedMods = profileMods.filter(m => m.id !== modId);
|
||||
profileManager.updateProfile(activeProfile.id, { mods: updatedMods });
|
||||
|
||||
console.log('Mod removed from profile');
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error uninstalling mod:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMod(modId, modsPath) {
|
||||
try {
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
if (!activeProfile) throw new Error('No active profile');
|
||||
|
||||
const profileMods = activeProfile.mods || [];
|
||||
const modIndex = profileMods.findIndex(m => m.id === modId);
|
||||
|
||||
if (modIndex === -1) {
|
||||
throw new Error('Mod not found in profile');
|
||||
}
|
||||
|
||||
const mod = profileMods[modIndex];
|
||||
const newEnabled = !mod.enabled; // Toggle
|
||||
|
||||
// Update Profile First
|
||||
const updatedMods = [...profileMods];
|
||||
updatedMods[modIndex] = { ...mod, enabled: newEnabled };
|
||||
profileManager.updateProfile(activeProfile.id, { mods: updatedMods });
|
||||
|
||||
// Move file between Profile/Mods and Profile/DisabledMods
|
||||
const profileModsPath = getProfileModsPath(activeProfile.id);
|
||||
const disabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods');
|
||||
|
||||
if (!fs.existsSync(disabledModsPath)) fs.mkdirSync(disabledModsPath, { recursive: true });
|
||||
|
||||
const currentPath = mod.enabled ? path.join(profileModsPath, mod.fileName) : path.join(disabledModsPath, mod.fileName);
|
||||
const targetDir = newEnabled ? profileModsPath : disabledModsPath;
|
||||
const targetPath = path.join(targetDir, mod.fileName);
|
||||
|
||||
if (fs.existsSync(currentPath)) {
|
||||
fs.renameSync(currentPath, targetPath);
|
||||
} else {
|
||||
// Fallback: check if it's already in target?
|
||||
if (fs.existsSync(targetPath)) {
|
||||
console.log(`[ModManager] Mod ${mod.fileName} is already in the correct state`);
|
||||
} else {
|
||||
// Try finding it
|
||||
const altPath = mod.enabled ? path.join(disabledModsPath, mod.fileName) : path.join(profileModsPath, mod.fileName);
|
||||
if (fs.existsSync(altPath)) fs.renameSync(altPath, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, enabled: newEnabled };
|
||||
} catch (error) {
|
||||
console.error('Error toggling mod:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function syncModsForCurrentProfile() {
|
||||
try {
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
if (!activeProfile) {
|
||||
console.warn('No active profile found during mod sync');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[ModManager] Syncing mods for profile: ${activeProfile.name} (${activeProfile.id})`);
|
||||
|
||||
// 1. Resolve Paths
|
||||
// globalModsPath is the one the game uses (symlink target)
|
||||
const globalModsPath = await getModsPath();
|
||||
// profileModsPath is the real storage for this profile
|
||||
const profileModsPath = getProfileModsPath(activeProfile.id);
|
||||
const profileDisabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods');
|
||||
|
||||
if (!fs.existsSync(profileDisabledModsPath)) {
|
||||
fs.mkdirSync(profileDisabledModsPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 2. Symlink / Migration Logic
|
||||
let needsLink = false;
|
||||
|
||||
if (fs.existsSync(globalModsPath)) {
|
||||
const stats = fs.lstatSync(globalModsPath);
|
||||
|
||||
if (stats.isSymbolicLink()) {
|
||||
const linkTarget = fs.readlinkSync(globalModsPath);
|
||||
// Normalize paths for comparison
|
||||
if (path.resolve(linkTarget) !== path.resolve(profileModsPath)) {
|
||||
console.log(`[ModManager] Updating symlink from ${linkTarget} to ${profileModsPath}`);
|
||||
fs.unlinkSync(globalModsPath);
|
||||
needsLink = true;
|
||||
}
|
||||
} else if (stats.isDirectory()) {
|
||||
// MIGRATION: It's a real directory. Move contents to profile.
|
||||
console.log('[ModManager] Migrating global mods folder to profile folder...');
|
||||
const files = fs.readdirSync(globalModsPath);
|
||||
for (const file of files) {
|
||||
const src = path.join(globalModsPath, file);
|
||||
const dest = path.join(profileModsPath, file);
|
||||
// Only move if dest doesn't exist to avoid overwriting
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.renameSync(src, dest);
|
||||
}
|
||||
}
|
||||
|
||||
// Also migrate DisabledMods if it exists globally
|
||||
const globalDisabledPath = path.join(path.dirname(globalModsPath), 'DisabledMods');
|
||||
if (fs.existsSync(globalDisabledPath) && fs.lstatSync(globalDisabledPath).isDirectory()) {
|
||||
const dFiles = fs.readdirSync(globalDisabledPath);
|
||||
for (const file of dFiles) {
|
||||
const src = path.join(globalDisabledPath, file);
|
||||
const dest = path.join(profileDisabledModsPath, file);
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.renameSync(src, dest);
|
||||
}
|
||||
}
|
||||
// We can remove global DisabledMods now, as it's not used by game
|
||||
try { fs.rmSync(globalDisabledPath, { recursive: true, force: true }); } catch(e) {}
|
||||
}
|
||||
|
||||
// Remove the directory so we can link it
|
||||
try {
|
||||
fs.rmSync(globalModsPath, { recursive: true, force: true });
|
||||
needsLink = true;
|
||||
} catch (e) {
|
||||
console.error('Failed to remove global mods dir:', e);
|
||||
// Throw error to stop.
|
||||
throw new Error('Failed to migrate mods directory. Please clear ' + globalModsPath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
needsLink = true;
|
||||
}
|
||||
|
||||
if (needsLink) {
|
||||
console.log(`[ModManager] Creating symlink: ${globalModsPath} -> ${profileModsPath}`);
|
||||
try {
|
||||
// 'junction' is key for Windows without admin
|
||||
fs.symlinkSync(profileModsPath, globalModsPath, 'junction');
|
||||
} catch (err) {
|
||||
// If we can't create the symlink, try creating the directory first
|
||||
console.error('[ModManager] Failed to create symlink. Falling back to direct folder mode.');
|
||||
console.error(err.message);
|
||||
|
||||
// Fallback: create a real directory so the game still works
|
||||
if (!fs.existsSync(globalModsPath)) {
|
||||
fs.mkdirSync(globalModsPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Auto-Repair (Download missing mods)
|
||||
const profileModsSnapshot = activeProfile.mods || [];
|
||||
for (const mod of profileModsSnapshot) {
|
||||
if (mod.enabled && !mod.manual) {
|
||||
const inEnabled = fs.existsSync(path.join(profileModsPath, mod.fileName));
|
||||
const inDisabled = fs.existsSync(path.join(profileDisabledModsPath, mod.fileName));
|
||||
|
||||
if (!inEnabled && !inDisabled) {
|
||||
if (mod.curseForgeId && (mod.curseForgeFileId || mod.fileId)) {
|
||||
console.log(`[ModManager] Auto-repair: Re-downloading missing mod "${mod.name}"...`);
|
||||
try {
|
||||
await downloadMod({
|
||||
...mod,
|
||||
modId: mod.curseForgeId,
|
||||
fileId: mod.curseForgeFileId || mod.fileId,
|
||||
apiKey: API_KEY
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[ModManager] Auto-repair failed for "${mod.name}": ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Auto-Import (Detect manual drops in the profile folder)
|
||||
const enabledFiles = fs.existsSync(profileModsPath) ? fs.readdirSync(profileModsPath).filter(f => f.endsWith('.jar') || f.endsWith('.zip')) : [];
|
||||
|
||||
let profileMods = activeProfile.mods || [];
|
||||
let profileUpdated = false;
|
||||
|
||||
|
||||
// Anything in this folder belongs to this profile.
|
||||
|
||||
for (const file of enabledFiles) {
|
||||
const isKnown = profileMods.some(m => m.fileName === file);
|
||||
|
||||
if (!isKnown) {
|
||||
console.log(`[ModManager] Auto-importing manual mod: ${file}`);
|
||||
const newMod = {
|
||||
id: generateModId(file),
|
||||
name: extractModName(file),
|
||||
version: 'Unknown',
|
||||
description: 'Manually installed',
|
||||
author: 'Local',
|
||||
enabled: true,
|
||||
fileName: file,
|
||||
fileSize: 0,
|
||||
dateInstalled: new Date().toISOString(),
|
||||
manual: true
|
||||
};
|
||||
profileMods.push(newMod);
|
||||
profileUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (profileUpdated) {
|
||||
profileManager.updateProfile(activeProfile.id, { mods: profileMods });
|
||||
const updatedProfile = profileManager.getActiveProfile();
|
||||
profileMods = updatedProfile ? (updatedProfile.mods || []) : profileMods;
|
||||
}
|
||||
|
||||
// 5. Enforce Enabled/Disabled State (Move files between Profile/Mods and Profile/DisabledMods)
|
||||
// Note: Since Global/Mods IS Profile/Mods (via symlink), moving out of Profile/Mods disables it for the game.
|
||||
|
||||
const disabledFiles = fs.existsSync(profileDisabledModsPath) ? fs.readdirSync(profileDisabledModsPath).filter(f => f.endsWith('.jar') || f.endsWith('.zip')) : [];
|
||||
const allFiles = new Set([...enabledFiles, ...disabledFiles]);
|
||||
|
||||
for (const fileName of allFiles) {
|
||||
const modConfig = profileMods.find(m => m.fileName === fileName);
|
||||
const shouldBeEnabled = modConfig && modConfig.enabled !== false;
|
||||
|
||||
const currentPath = enabledFiles.includes(fileName) ? path.join(profileModsPath, fileName) : path.join(profileDisabledModsPath, fileName);
|
||||
const targetDir = shouldBeEnabled ? profileModsPath : profileDisabledModsPath;
|
||||
const targetPath = path.join(targetDir, fileName);
|
||||
|
||||
if (path.dirname(currentPath) !== targetDir) {
|
||||
console.log(`[Mod Sync] Moving ${fileName} to ${shouldBeEnabled ? 'Enabled' : 'Disabled'}`);
|
||||
try {
|
||||
fs.renameSync(currentPath, targetPath);
|
||||
} catch (err) {
|
||||
console.error(`Failed to move ${fileName}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ModManager] Error syncing mods:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadInstalledMods,
|
||||
downloadMod,
|
||||
uninstallMod,
|
||||
toggleMod,
|
||||
syncModsForCurrentProfile,
|
||||
generateModId,
|
||||
extractModName,
|
||||
extractVersion
|
||||
};
|
||||
0
backend/managers/multiClientManager.js
Normal file
0
backend/managers/multiClientManager.js
Normal file
206
backend/managers/profileManager.js
Normal file
206
backend/managers/profileManager.js
Normal file
@@ -0,0 +1,206 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const {
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
getModsPath
|
||||
} = require('../core/config');
|
||||
|
||||
// Lazy-load modManager to avoid circular deps, or keep imports structured.
|
||||
// For now, access mod paths directly or use helper helpers.
|
||||
|
||||
class ProfileManager {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.initialized) return;
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
// Migration: specific check to see if we have profiles yet
|
||||
if (!config.profiles || Object.keys(config.profiles).length === 0) {
|
||||
this.migrateLegacyConfig(config);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[ProfileManager] Initialized');
|
||||
}
|
||||
|
||||
migrateLegacyConfig(config) {
|
||||
console.log('[ProfileManager] Migrating legacy config to profile system...');
|
||||
|
||||
// Create a default profile with current settings
|
||||
const defaultProfileId = 'default';
|
||||
const defaultProfile = {
|
||||
id: defaultProfileId,
|
||||
name: 'Default',
|
||||
created: new Date().toISOString(),
|
||||
lastUsed: new Date().toISOString(),
|
||||
|
||||
// settings specific to this profile
|
||||
// If global settings existed, we copy them here
|
||||
mods: config.installedMods || [], // Legacy mods are now part of default profile
|
||||
javaPath: config.javaPath || '',
|
||||
gameOptions: {
|
||||
minMemory: '1G',
|
||||
maxMemory: '4G',
|
||||
args: []
|
||||
}
|
||||
};
|
||||
|
||||
const updates = {
|
||||
profiles: {
|
||||
[defaultProfileId]: defaultProfile
|
||||
},
|
||||
activeProfileId: defaultProfileId,
|
||||
// Mods are currently treated as files on disk.
|
||||
// The profile's `mods` array is the source of truth for enabled/known mods per profile.
|
||||
|
||||
};
|
||||
|
||||
saveConfig(updates);
|
||||
console.log('[ProfileManager] Migration complete. Created Default profile.');
|
||||
}
|
||||
|
||||
createProfile(name) {
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw new Error('Invalid profile name');
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const id = uuidv4();
|
||||
|
||||
const newProfile = {
|
||||
id,
|
||||
name: name.trim(),
|
||||
created: new Date().toISOString(),
|
||||
lastUsed: null,
|
||||
mods: [], // Start with no mods enabled
|
||||
javaPath: '',
|
||||
gameOptions: {
|
||||
minMemory: '1G',
|
||||
maxMemory: '4G',
|
||||
args: []
|
||||
}
|
||||
};
|
||||
|
||||
const profiles = config.profiles || {};
|
||||
profiles[id] = newProfile;
|
||||
|
||||
saveConfig({ profiles });
|
||||
|
||||
console.log(`[ProfileManager] Created new profile: "${name}" (${id})`);
|
||||
return newProfile;
|
||||
}
|
||||
|
||||
getProfiles() {
|
||||
const config = loadConfig();
|
||||
return Object.values(config.profiles || {});
|
||||
}
|
||||
|
||||
getProfile(id) {
|
||||
const config = loadConfig();
|
||||
return (config.profiles && config.profiles[id]) || null;
|
||||
}
|
||||
|
||||
getActiveProfile() {
|
||||
const config = loadConfig();
|
||||
const activeId = config.activeProfileId;
|
||||
if (!activeId || !config.profiles || !config.profiles[activeId]) {
|
||||
// Fallback if something is corrupted
|
||||
return this.getProfiles()[0] || null;
|
||||
}
|
||||
return config.profiles[activeId];
|
||||
}
|
||||
|
||||
async activateProfile(id) {
|
||||
const config = loadConfig();
|
||||
if (!config.profiles || !config.profiles[id]) {
|
||||
throw new Error(`Profile not found: ${id}`);
|
||||
}
|
||||
|
||||
if (config.activeProfileId === id) {
|
||||
console.log(`[ProfileManager] Profile ${id} is already active.`);
|
||||
return config.profiles[id];
|
||||
}
|
||||
|
||||
console.log(`[ProfileManager] Switching to profile: ${config.profiles[id].name} (${id})`);
|
||||
|
||||
// 1. Update config first
|
||||
config.profiles[id].lastUsed = new Date().toISOString();
|
||||
saveConfig({
|
||||
activeProfileId: id,
|
||||
profiles: config.profiles
|
||||
});
|
||||
|
||||
// 2. Trigger Mod Sync
|
||||
// We need to require this here to ensure it uses the *newly saved* active profile ID
|
||||
const { syncModsForCurrentProfile } = require('./modManager');
|
||||
await syncModsForCurrentProfile();
|
||||
|
||||
return config.profiles[id];
|
||||
}
|
||||
|
||||
deleteProfile(id) {
|
||||
const config = loadConfig();
|
||||
const profiles = config.profiles || {};
|
||||
|
||||
if (!profiles[id]) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
if (config.activeProfileId === id) {
|
||||
throw new Error('Cannot delete the active profile');
|
||||
}
|
||||
|
||||
// Don't allow deleting the last profile
|
||||
if (Object.keys(profiles).length <= 1) {
|
||||
throw new Error('Cannot delete the only remaining profile');
|
||||
}
|
||||
|
||||
delete profiles[id];
|
||||
saveConfig({ profiles });
|
||||
console.log(`[ProfileManager] Deleted profile: ${id}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
updateProfile(id, updates) {
|
||||
const config = loadConfig();
|
||||
const profiles = config.profiles || {};
|
||||
|
||||
if (!profiles[id]) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
// Safety checks on updates
|
||||
const allowedFields = ['name', 'javaPath', 'gameOptions', 'mods'];
|
||||
const sanitizedUpdates = {};
|
||||
|
||||
Object.keys(updates).forEach(key => {
|
||||
if (allowedFields.includes(key)) {
|
||||
sanitizedUpdates[key] = updates[key];
|
||||
}
|
||||
});
|
||||
|
||||
profiles[id] = { ...profiles[id], ...sanitizedUpdates };
|
||||
|
||||
saveConfig({ profiles });
|
||||
console.log(`[ProfileManager] Updated profile: ${id}`);
|
||||
|
||||
// If we updated mods for the *active* profile, we might need to sync immediately
|
||||
if (config.activeProfileId === id && updates.mods) {
|
||||
// Optionally trigger sync?
|
||||
// Sync is usually triggered when toggling a single mod.
|
||||
// For bulk updates, the caller should decide when to sync.
|
||||
|
||||
}
|
||||
|
||||
return profiles[id];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ProfileManager();
|
||||
116
backend/managers/uiFileManager.js
Normal file
116
backend/managers/uiFileManager.js
Normal file
@@ -0,0 +1,116 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { downloadFile, findHomePageUIPath, findLogoPath } = require('../utils/fileManager');
|
||||
|
||||
async function downloadAndReplaceHomePageUI(gameDir, progressCallback) {
|
||||
try {
|
||||
console.log('Downloading HomePage.ui from server...');
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Downloading HomePage.ui...', null, null, null, null);
|
||||
}
|
||||
|
||||
const homeUIUrl = 'https://files.hytalef2p.com/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 = 'https://files.hytalef2p.com/api/Logo';
|
||||
const tempLogoPath = path.join(path.dirname(gameDir), 'Logo@2x_temp.png');
|
||||
|
||||
await downloadFile(logoUrl, tempLogoPath);
|
||||
|
||||
const existingLogoPath = findLogoPath(gameDir);
|
||||
|
||||
if (existingLogoPath && fs.existsSync(existingLogoPath)) {
|
||||
console.log('Found existing Logo@2x.png at:', existingLogoPath);
|
||||
|
||||
const backupPath = existingLogoPath + '.backup';
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
fs.copyFileSync(existingLogoPath, backupPath);
|
||||
console.log('Original Logo@2x.png backed up');
|
||||
}
|
||||
|
||||
fs.copyFileSync(tempLogoPath, existingLogoPath);
|
||||
console.log('Logo@2x.png replaced successfully');
|
||||
} else {
|
||||
console.log('No existing Logo@2x.png found, skipping replacement');
|
||||
}
|
||||
|
||||
if (fs.existsSync(tempLogoPath)) {
|
||||
fs.unlinkSync(tempLogoPath);
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Logo@2x.png updated', null, null, null, null);
|
||||
}
|
||||
|
||||
return { success: true, updated: true };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error downloading/replacing Logo@2x.png:', error);
|
||||
|
||||
const tempLogoPath = path.join(path.dirname(gameDir), 'Logo@2x_temp.png');
|
||||
if (fs.existsSync(tempLogoPath)) {
|
||||
fs.unlinkSync(tempLogoPath);
|
||||
}
|
||||
|
||||
console.log('Logo@2x.png update failed, continuing...');
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
downloadAndReplaceHomePageUI,
|
||||
findHomePageUIPath,
|
||||
downloadAndReplaceLogo,
|
||||
findLogoPath
|
||||
};
|
||||
105
backend/services/firstLaunch.js
Normal file
105
backend/services/firstLaunch.js
Normal file
@@ -0,0 +1,105 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { markAsLaunched, loadConfig } = require('../core/config');
|
||||
const { checkExistingGameInstallation, updateGameFiles } = require('../managers/gameManager');
|
||||
const { getInstalledClientVersion, getLatestClientVersion } = require('./versionManager');
|
||||
|
||||
async function proposeGameUpdate(existingGame, progressCallback) {
|
||||
try {
|
||||
console.log('Proposing game update for existing installation...');
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Checking for game updates...', 0, null, null, null);
|
||||
}
|
||||
|
||||
const [installedVersion, latestVersion] = await Promise.all([
|
||||
getInstalledClientVersion(),
|
||||
getLatestClientVersion()
|
||||
]);
|
||||
|
||||
console.log(`Existing installation - Installed: ${installedVersion}, Latest: ${latestVersion}`);
|
||||
|
||||
const customAppDir = path.join(existingGame.installPath, 'HytaleF2P');
|
||||
const customCacheDir = path.join(customAppDir, 'cache');
|
||||
const customToolsDir = path.join(customAppDir, 'butler');
|
||||
|
||||
[customCacheDir, customToolsDir].forEach(dir => {
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Updating existing game installation...', 20, null, null, null);
|
||||
}
|
||||
|
||||
await updateGameFiles(latestVersion, progressCallback, existingGame.gameDir, customToolsDir, customCacheDir);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Game update completed successfully', 100, null, null, null);
|
||||
}
|
||||
|
||||
console.log('Existing game installation updated successfully');
|
||||
return { success: true, updated: true };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating existing game:', error);
|
||||
if (progressCallback) {
|
||||
progressCallback(`Update failed: ${error.message}`, -1, null, null, null);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFirstLaunchCheck(progressCallback) {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
|
||||
if (config.hasLaunchedBefore === true) {
|
||||
return { isFirstLaunch: false, needsUpdate: false };
|
||||
}
|
||||
|
||||
console.log('First launch detected, checking for existing game installation...');
|
||||
|
||||
const existingGame = checkExistingGameInstallation();
|
||||
|
||||
if (!existingGame) {
|
||||
console.log('No existing game installation found');
|
||||
|
||||
const hasUserData = config.installPath || config.username || config.javaPath ||
|
||||
config.chatUsername || config.userUuids ||
|
||||
Object.keys(config).length > 0;
|
||||
|
||||
if (hasUserData) {
|
||||
console.log('Detected existing user data but no game, marking as launched');
|
||||
markAsLaunched();
|
||||
return { isFirstLaunch: false, needsUpdate: false };
|
||||
} else {
|
||||
markAsLaunched();
|
||||
return { isFirstLaunch: true, needsUpdate: false, existingGame: null };
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Existing game installation found:', {
|
||||
gameDir: existingGame.gameDir,
|
||||
hasUserData: existingGame.hasUserData
|
||||
});
|
||||
|
||||
return {
|
||||
isFirstLaunch: true,
|
||||
needsUpdate: true,
|
||||
existingGame: existingGame
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in first launch check:', error);
|
||||
markAsLaunched();
|
||||
return { isFirstLaunch: true, needsUpdate: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
proposeGameUpdate,
|
||||
handleFirstLaunchCheck
|
||||
};
|
||||
31
backend/services/newsManager.js
Normal file
31
backend/services/newsManager.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const axios = require('axios');
|
||||
|
||||
async function getHytaleNews() {
|
||||
try {
|
||||
const response = await axios.get('https://launcher.hytale.com/launcher-feed/release/feed.json', {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
},
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const articles = response.data.articles || [];
|
||||
return articles.map(article => ({
|
||||
title: article.title || '',
|
||||
description: article.description || '',
|
||||
destUrl: article.dest_url || '',
|
||||
imageUrl: article.image_url ?
|
||||
(article.image_url.startsWith('http') ?
|
||||
article.image_url :
|
||||
`https://launcher.hytale.com/launcher-feed/release/${article.image_url}`
|
||||
) : ''
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch news:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getHytaleNews
|
||||
};
|
||||
34
backend/services/playerManager.js
Normal file
34
backend/services/playerManager.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { PLAYER_ID_FILE, APP_DIR } = require('../core/paths');
|
||||
|
||||
function getOrCreatePlayerId() {
|
||||
try {
|
||||
if (!fs.existsSync(APP_DIR)) {
|
||||
fs.mkdirSync(APP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(PLAYER_ID_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(PLAYER_ID_FILE, 'utf8'));
|
||||
if (data.playerId) {
|
||||
return data.playerId;
|
||||
}
|
||||
}
|
||||
|
||||
const newPlayerId = uuidv4();
|
||||
fs.writeFileSync(PLAYER_ID_FILE, JSON.stringify({
|
||||
playerId: newPlayerId,
|
||||
createdAt: new Date().toISOString()
|
||||
}, null, 2));
|
||||
|
||||
return newPlayerId;
|
||||
} catch (error) {
|
||||
console.error('Error managing player ID:', error);
|
||||
return uuidv4();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOrCreatePlayerId
|
||||
};
|
||||
56
backend/services/versionManager.js
Normal file
56
backend/services/versionManager.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const axios = require('axios');
|
||||
|
||||
async function getLatestClientVersion() {
|
||||
try {
|
||||
console.log('Fetching latest client version from API...');
|
||||
const response = await axios.get('https://files.hytalef2p.com/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('https://files.hytalef2p.com/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;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getLatestClientVersion,
|
||||
getInstalledClientVersion
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const UPDATE_CHECK_URL = 'http://3.10.208.30:3002/api/version_launcher';
|
||||
const CURRENT_VERSION = '2.0.0';
|
||||
const UPDATE_CHECK_URL = 'https://files.hytalef2p.com/api/version_launcher';
|
||||
const CURRENT_VERSION = '2.0.2';
|
||||
const GITHUB_DOWNLOAD_URL = 'https://github.com/amiayweb/Hytale-F2P/';
|
||||
|
||||
class UpdateManager {
|
||||
|
||||
494
backend/utils/clientPatcher.js
Normal file
494
backend/utils/clientPatcher.js
Normal file
@@ -0,0 +1,494 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const AdmZip = require('adm-zip');
|
||||
|
||||
// Domain configuration
|
||||
const ORIGINAL_DOMAIN = 'hytale.com';
|
||||
|
||||
function getTargetDomain() {
|
||||
if (process.env.HYTALE_AUTH_DOMAIN) {
|
||||
return process.env.HYTALE_AUTH_DOMAIN;
|
||||
}
|
||||
try {
|
||||
const { getAuthDomain } = require('../core/config');
|
||||
return getAuthDomain();
|
||||
} catch (e) {
|
||||
return 'sanasol.ws';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
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);
|
||||
|
||||
const positions = this.findAllOccurrences(result, oldUtf8);
|
||||
|
||||
for (const pos of positions) {
|
||||
newUtf8.copy(result, pos);
|
||||
count++;
|
||||
console.log(` Patched UTF-8 occurrence at offset 0x${pos.toString(16)}`);
|
||||
}
|
||||
|
||||
return { buffer: result, count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart domain replacement that handles both null-terminated and non-null-terminated strings.
|
||||
* .NET AOT stores some strings in various formats:
|
||||
* - Standard UTF-16LE (each char is 2 bytes with \x00 high byte)
|
||||
* - Length-prefixed where last char may have metadata byte instead of \x00
|
||||
*/
|
||||
findAndReplaceDomainSmart(data, oldDomain, newDomain) {
|
||||
let count = 0;
|
||||
const result = Buffer.from(data);
|
||||
|
||||
const oldUtf16NoLast = this.stringToUtf16LE(oldDomain.slice(0, -1));
|
||||
const newUtf16NoLast = this.stringToUtf16LE(newDomain.slice(0, -1));
|
||||
const oldLastChar = this.stringToUtf16LE(oldDomain.slice(-1));
|
||||
const newLastChar = this.stringToUtf16LE(newDomain.slice(-1));
|
||||
|
||||
const oldLastCharByte = oldDomain.charCodeAt(oldDomain.length - 1);
|
||||
const newLastCharByte = newDomain.charCodeAt(newDomain.length - 1);
|
||||
|
||||
const positions = this.findAllOccurrences(result, oldUtf16NoLast);
|
||||
|
||||
for (const pos of positions) {
|
||||
const lastCharPos = pos + oldUtf16NoLast.length;
|
||||
if (lastCharPos + 1 > result.length) continue;
|
||||
|
||||
const lastCharFirstByte = result[lastCharPos];
|
||||
|
||||
if (lastCharFirstByte === oldLastCharByte) {
|
||||
newUtf16NoLast.copy(result, pos);
|
||||
|
||||
result[lastCharPos] = newLastCharByte;
|
||||
|
||||
if (lastCharPos + 1 < result.length) {
|
||||
const secondByte = result[lastCharPos + 1];
|
||||
if (secondByte === 0x00) {
|
||||
console.log(` Patched UTF-16LE occurrence at offset 0x${pos.toString(16)}`);
|
||||
} else {
|
||||
console.log(` Patched length-prefixed occurrence at offset 0x${pos.toString(16)} (metadata: 0x${secondByte.toString(16)})`);
|
||||
}
|
||||
}
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return { buffer: result, count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch Discord invite URLs from .gg/hytale to .gg/MHkEjepMQ7
|
||||
*/
|
||||
patchDiscordUrl(data) {
|
||||
let count = 0;
|
||||
const result = Buffer.from(data);
|
||||
|
||||
const oldUrl = '.gg/hytale';
|
||||
const newUrl = '.gg/MHkEjepMQ7';
|
||||
|
||||
const oldUtf16 = this.stringToUtf16LE(oldUrl);
|
||||
const newUtf16 = this.stringToUtf16LE(newUrl);
|
||||
|
||||
const positions = this.findAllOccurrences(result, oldUtf16);
|
||||
|
||||
for (const pos of positions) {
|
||||
newUtf16.copy(result, pos);
|
||||
count++;
|
||||
}
|
||||
|
||||
return { buffer: result, count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 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'));
|
||||
if (flagData.targetDomain === newDomain) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
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}`);
|
||||
|
||||
if (!fs.existsSync(clientPath)) {
|
||||
const error = `Client binary not found: ${clientPath}`;
|
||||
console.error(error);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
console.log('Creating backup...');
|
||||
this.backupClient(clientPath);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Reading client binary...', 20);
|
||||
}
|
||||
|
||||
console.log('Reading client binary...');
|
||||
const data = fs.readFileSync(clientPath);
|
||||
console.log(`Binary size: ${(data.length / 1024 / 1024).toFixed(2)} MB`);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Patching domain references...', 50);
|
||||
}
|
||||
|
||||
console.log('Patching domain references...');
|
||||
const { buffer: patchedData, count } = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, newDomain);
|
||||
|
||||
console.log('Patching Discord URLs...');
|
||||
const { buffer: finalData, count: discordCount } = this.patchDiscordUrl(patchedData);
|
||||
|
||||
if (count === 0 && discordCount === 0) {
|
||||
console.log('No occurrences found - binary may already be modified or has different format');
|
||||
return { success: true, patchCount: 0, warning: 'No occurrences found' };
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Writing patched binary...', 80);
|
||||
}
|
||||
|
||||
console.log('Writing patched binary...');
|
||||
fs.writeFileSync(clientPath, finalData);
|
||||
|
||||
this.markAsPatched(clientPath);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Patching complete', 100);
|
||||
}
|
||||
|
||||
console.log(`Successfully patched ${count} domain occurrences and ${discordCount} Discord URLs`);
|
||||
console.log('=== Patching Complete ===');
|
||||
|
||||
return { success: true, patchCount: count + discordCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
const error = `Server JAR not found: ${serverPath}`;
|
||||
console.error(error);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
console.log('Creating backup...');
|
||||
this.backupClient(serverPath);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback('Extracting server JAR...', 20);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
let totalCount = 0;
|
||||
const oldUtf8 = this.stringToUtf8(ORIGINAL_DOMAIN);
|
||||
const newUtf8 = this.stringToUtf8(newDomain);
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = entry.entryName;
|
||||
if (name.endsWith('.class') || name.endsWith('.properties') ||
|
||||
name.endsWith('.json') || name.endsWith('.xml') || name.endsWith('.yml')) {
|
||||
|
||||
const data = entry.getData();
|
||||
|
||||
if (data.includes(oldUtf8)) {
|
||||
const { buffer: patchedData, count } = this.findAndReplaceDomainUtf8(data, ORIGINAL_DOMAIN, 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);
|
||||
}
|
||||
|
||||
console.log('Writing patched JAR...');
|
||||
zip.writeZip(serverPath);
|
||||
|
||||
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') {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClientPatcher();
|
||||
212
backend/utils/fileManager.js
Normal file
212
backend/utils/fileManager.js
Normal file
@@ -0,0 +1,212 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const axios = require('axios');
|
||||
|
||||
async function downloadFile(url, dest, progressCallback, maxRetries = 3) {
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`Download attempt ${attempt + 1}/${maxRetries} for ${url}`);
|
||||
|
||||
if (attempt > 0 && progressCallback) {
|
||||
progressCallback(`Retry ${attempt}/${maxRetries - 1}...`, null, null, null, null);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000 * attempt)); // Délai progressif
|
||||
}
|
||||
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
responseType: 'stream',
|
||||
timeout: 60000, // 60 secondes timeout
|
||||
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/',
|
||||
'Connection': 'keep-alive'
|
||||
},
|
||||
// Configuration Axios pour la robustesse réseau
|
||||
validateStatus: function (status) {
|
||||
return status >= 200 && status < 300;
|
||||
},
|
||||
// Retry configuration
|
||||
maxRedirects: 5,
|
||||
// Network resilience
|
||||
family: 4 // Force IPv4
|
||||
});
|
||||
|
||||
const totalSize = parseInt(response.headers['content-length'], 10);
|
||||
let downloaded = 0;
|
||||
let lastProgressTime = Date.now();
|
||||
const startTime = Date.now();
|
||||
|
||||
// Nettoyer le fichier de destination s'il existe
|
||||
if (fs.existsSync(dest)) {
|
||||
fs.unlinkSync(dest);
|
||||
}
|
||||
|
||||
const writer = fs.createWriteStream(dest);
|
||||
let downloadStalled = false;
|
||||
let stalledTimeout = null;
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
downloaded += chunk.length;
|
||||
const now = Date.now();
|
||||
|
||||
// Reset stalled timer on data received
|
||||
if (stalledTimeout) {
|
||||
clearTimeout(stalledTimeout);
|
||||
}
|
||||
|
||||
// Set new stalled timer (30 seconds without data = stalled)
|
||||
stalledTimeout = setTimeout(() => {
|
||||
downloadStalled = true;
|
||||
writer.destroy();
|
||||
response.data.destroy();
|
||||
}, 30000);
|
||||
|
||||
if (progressCallback && totalSize > 0 && (now - lastProgressTime > 100)) { // Update every 100ms max
|
||||
const percent = Math.min(100, Math.max(0, (downloaded / totalSize) * 100));
|
||||
const elapsed = (now - startTime) / 1000;
|
||||
const speed = elapsed > 0 ? downloaded / elapsed : 0;
|
||||
progressCallback(null, percent, speed, downloaded, totalSize);
|
||||
lastProgressTime = now;
|
||||
}
|
||||
});
|
||||
|
||||
response.data.on('error', (error) => {
|
||||
if (stalledTimeout) {
|
||||
clearTimeout(stalledTimeout);
|
||||
}
|
||||
console.error(`Stream error on attempt ${attempt + 1}:`, error.code || error.message);
|
||||
writer.destroy();
|
||||
});
|
||||
|
||||
response.data.pipe(writer);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
writer.on('finish', () => {
|
||||
if (stalledTimeout) {
|
||||
clearTimeout(stalledTimeout);
|
||||
}
|
||||
if (!downloadStalled) {
|
||||
console.log(`Download completed successfully on attempt ${attempt + 1}`);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('Download stalled'));
|
||||
}
|
||||
});
|
||||
|
||||
writer.on('error', (error) => {
|
||||
if (stalledTimeout) {
|
||||
clearTimeout(stalledTimeout);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
response.data.on('error', (error) => {
|
||||
if (stalledTimeout) {
|
||||
clearTimeout(stalledTimeout);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
// Si on arrive ici, le téléchargement a réussi
|
||||
return;
|
||||
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
console.error(`Download attempt ${attempt + 1} failed:`, error.code || error.message);
|
||||
|
||||
// Nettoyer le fichier partiel en cas d'erreur
|
||||
if (fs.existsSync(dest)) {
|
||||
try {
|
||||
fs.unlinkSync(dest);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Could not cleanup partial file:', cleanupError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier si c'est une erreur réseau que l'on peut retry
|
||||
const retryableErrors = ['ECONNRESET', 'ENOTFOUND', 'ECONNREFUSED', 'ETIMEDOUT', 'ESOCKETTIMEDOUT', 'EPROTO'];
|
||||
const isRetryable = retryableErrors.includes(error.code) ||
|
||||
error.message.includes('timeout') ||
|
||||
error.message.includes('stalled') ||
|
||||
(error.response && error.response.status >= 500);
|
||||
|
||||
if (!isRetryable || attempt === maxRetries - 1) {
|
||||
console.error(`Non-retryable error or max retries reached: ${error.code || error.message}`);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`Retryable error detected, will retry in ${2000 * (attempt + 1)}ms...`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Download failed after ${maxRetries} attempts. Last error: ${lastError?.code || lastError?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
247
backend/utils/platformUtils.js
Normal file
247
backend/utils/platformUtils.js
Normal file
@@ -0,0 +1,247 @@
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function detectGpu() {
|
||||
const platform = getOS();
|
||||
|
||||
try {
|
||||
if (platform === 'linux') {
|
||||
return detectGpuLinux();
|
||||
} else if (platform === 'windows') {
|
||||
return detectGpuWindows();
|
||||
} else if (platform === 'darwin') {
|
||||
return detectGpuMac();
|
||||
} else {
|
||||
return { mode: 'integrated', vendor: 'intel', integratedName: 'Unknown', dedicatedName: null };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('GPU detection failed, falling back to integrated:', error.message);
|
||||
return { mode: 'integrated', vendor: 'intel', integratedName: 'Unknown', dedicatedName: null };
|
||||
}
|
||||
}
|
||||
|
||||
function detectGpuLinux() {
|
||||
const output = execSync('lspci -nn | grep \'VGA\\|3D\'', { encoding: 'utf8' });
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
|
||||
let integratedName = null;
|
||||
let dedicatedName = null;
|
||||
let hasNvidia = false;
|
||||
let hasAmd = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('VGA') || line.includes('3D')) {
|
||||
const match = line.match(/\[([^\]]+)\]/g);
|
||||
let modelName = null;
|
||||
if (match && match.length >= 2) {
|
||||
modelName = match[1].slice(1, -1);
|
||||
}
|
||||
|
||||
if (line.includes('10de:') || line.toLowerCase().includes('nvidia')) {
|
||||
hasNvidia = true;
|
||||
dedicatedName = "NVIDIA " + modelName || 'NVIDIA GPU';
|
||||
console.log('Detected NVIDIA GPU:', dedicatedName);
|
||||
} else if (line.includes('1002:') || line.toLowerCase().includes('amd') || line.toLowerCase().includes('radeon')) {
|
||||
hasAmd = true;
|
||||
dedicatedName = "AMD " + modelName || 'AMD GPU';
|
||||
console.log('Detected AMD GPU:', dedicatedName);
|
||||
} else if (line.includes('8086:') || line.toLowerCase().includes('intel')) {
|
||||
integratedName = "Intel " + modelName || 'Intel GPU';
|
||||
console.log('Detected Intel GPU:', integratedName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNvidia) {
|
||||
return { mode: 'dedicated', vendor: 'nvidia', integratedName: integratedName || 'Intel GPU', dedicatedName };
|
||||
} else if (hasAmd) {
|
||||
return { mode: 'dedicated', vendor: 'amd', integratedName: integratedName || 'Intel GPU', dedicatedName };
|
||||
} else {
|
||||
return { mode: 'integrated', vendor: 'intel', integratedName: integratedName || 'Intel GPU', dedicatedName: null };
|
||||
}
|
||||
}
|
||||
|
||||
function detectGpuWindows() {
|
||||
const output = execSync('wmic path win32_VideoController get name', { encoding: 'utf8' });
|
||||
const lines = output.split('\n').map(line => line.trim()).filter(line => line && line !== 'Name');
|
||||
|
||||
let integratedName = null;
|
||||
let dedicatedName = null;
|
||||
let hasNvidia = false;
|
||||
let hasAmd = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const lowerLine = line.toLowerCase();
|
||||
if (lowerLine.includes('nvidia')) {
|
||||
hasNvidia = true;
|
||||
dedicatedName = line;
|
||||
console.log('Detected NVIDIA GPU:', dedicatedName);
|
||||
} else if (lowerLine.includes('amd') || lowerLine.includes('radeon')) {
|
||||
hasAmd = true;
|
||||
dedicatedName = line;
|
||||
console.log('Detected AMD GPU:', dedicatedName);
|
||||
} else if (lowerLine.includes('intel')) {
|
||||
integratedName = line;
|
||||
console.log('Detected Intel GPU:', integratedName);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNvidia) {
|
||||
return { mode: 'dedicated', vendor: 'nvidia', integratedName: integratedName || 'Intel GPU', dedicatedName };
|
||||
} else if (hasAmd) {
|
||||
return { mode: 'dedicated', vendor: 'amd', integratedName: integratedName || 'Intel GPU', dedicatedName };
|
||||
} else {
|
||||
return { mode: 'integrated', vendor: 'intel', integratedName: integratedName || 'Intel GPU', dedicatedName: null };
|
||||
}
|
||||
}
|
||||
|
||||
function detectGpuMac() {
|
||||
const output = execSync('system_profiler SPDisplaysDataType', { encoding: 'utf8' });
|
||||
const lines = output.split('\n');
|
||||
|
||||
let integratedName = null;
|
||||
let dedicatedName = null;
|
||||
let hasNvidia = false;
|
||||
let hasAmd = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('Chipset Model:')) {
|
||||
const gpuName = line.split('Chipset Model:')[1].trim();
|
||||
const lowerGpu = gpuName.toLowerCase();
|
||||
if (lowerGpu.includes('nvidia')) {
|
||||
hasNvidia = true;
|
||||
dedicatedName = gpuName;
|
||||
console.log('Detected NVIDIA GPU:', dedicatedName);
|
||||
} else if (lowerGpu.includes('amd') || lowerGpu.includes('radeon')) {
|
||||
hasAmd = true;
|
||||
dedicatedName = gpuName;
|
||||
console.log('Detected AMD GPU:', dedicatedName);
|
||||
} else if (lowerGpu.includes('intel') || lowerGpu.includes('iris') || lowerGpu.includes('uhd')) {
|
||||
integratedName = gpuName;
|
||||
console.log('Detected Intel GPU:', integratedName);
|
||||
} else if (!dedicatedName && !integratedName) {
|
||||
// Fallback for Apple Silicon or other
|
||||
integratedName = gpuName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNvidia) {
|
||||
return { mode: 'dedicated', vendor: 'nvidia', integratedName: integratedName || 'Integrated GPU', dedicatedName };
|
||||
} else if (hasAmd) {
|
||||
return { mode: 'dedicated', vendor: 'amd', integratedName: integratedName || 'Integrated GPU', dedicatedName };
|
||||
} else {
|
||||
return { mode: 'integrated', vendor: 'intel', integratedName: integratedName || 'Integrated GPU', dedicatedName: null };
|
||||
}
|
||||
}
|
||||
|
||||
function setupGpuEnvironment(gpuPreference) {
|
||||
if (process.platform !== 'linux') {
|
||||
return {};
|
||||
}
|
||||
|
||||
let finalPreference = gpuPreference;
|
||||
let detected = detectGpu();
|
||||
|
||||
if (gpuPreference === 'auto') {
|
||||
finalPreference = detected.mode;
|
||||
console.log(`Auto-detected GPU: ${detected.vendor} (${detected.mode})`);
|
||||
}
|
||||
|
||||
console.log('Preferred GPU set to:', finalPreference);
|
||||
|
||||
const envVars = {};
|
||||
|
||||
if (finalPreference === 'dedicated') {
|
||||
if (detected.vendor === 'nvidia') {
|
||||
envVars.__NV_PRIME_RENDER_OFFLOAD = '1';
|
||||
envVars.__GLX_VENDOR_LIBRARY_NAME = 'nvidia';
|
||||
const nvidiaEglFile = '/usr/share/glvnd/egl_vendor.d/10_nvidia.json';
|
||||
if (fs.existsSync(nvidiaEglFile)) {
|
||||
envVars.__EGL_VENDOR_LIBRARY_FILENAMES = nvidiaEglFile;
|
||||
} else {
|
||||
console.warn('NVIDIA EGL vendor library file not found, not setting __EGL_VENDOR_LIBRARY_FILENAMES');
|
||||
}
|
||||
} else {
|
||||
envVars.DRI_PRIME = '1';
|
||||
}
|
||||
console.log('GPU environment variables:', envVars);
|
||||
} else {
|
||||
console.log('Using integrated GPU, no environment variables set');
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOS,
|
||||
getArch,
|
||||
isWaylandSession,
|
||||
setupWaylandEnvironment,
|
||||
detectGpu,
|
||||
setupGpuEnvironment
|
||||
};
|
||||
3
dev-app-update.yml
Normal file
3
dev-app-update.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
provider: github
|
||||
owner: amiayweb # Change to your own GitHub username
|
||||
repo: Hytale-F2P
|
||||
284
docs/AUTO-UPDATES.md
Normal file
284
docs/AUTO-UPDATES.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# Auto-Updates System
|
||||
|
||||
This document explains how the automatic update system works in the Hytale F2P Launcher.
|
||||
|
||||
## Overview
|
||||
|
||||
The launcher uses [electron-updater](https://www.electron.build/auto-update) to automatically check for, download, and install updates. When a new version is available, users are notified and the update is downloaded in the background.
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Update Checking
|
||||
|
||||
- **Automatic Check**: The app automatically checks for updates 3 seconds after startup
|
||||
- **Manual Check**: Users can manually check for updates through the UI
|
||||
- **Update Source**: Updates are fetched from GitHub Releases
|
||||
|
||||
### 2. Update Process
|
||||
|
||||
1. **Check for Updates**: The app queries GitHub Releases for a newer version
|
||||
2. **Notify User**: If an update is available, the user is notified via the UI
|
||||
3. **Download**: The update is automatically downloaded in the background
|
||||
4. **Progress Tracking**: Download progress is shown to the user
|
||||
5. **Install**: When the download completes, the user can choose to install immediately or wait until the app restarts
|
||||
|
||||
### 3. Installation
|
||||
|
||||
- Updates are installed when the app quits (if `autoInstallOnAppQuit` is enabled)
|
||||
- Users can also manually trigger installation through the UI
|
||||
- The app will restart automatically after installation
|
||||
|
||||
## Version Detection & Comparison
|
||||
|
||||
### Current Version Source
|
||||
|
||||
The app's current version is read from `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0.2b"
|
||||
}
|
||||
```
|
||||
|
||||
This version is embedded into the built application and is accessible via `app.getVersion()` in Electron. When the app is built, electron-builder also creates an internal `app-update.yml` file in the app's resources that contains this version information.
|
||||
|
||||
### How Version Detection Works
|
||||
|
||||
1. **Current Version**: The app knows its own version from `package.json`, which is:
|
||||
- Read at build time
|
||||
- Embedded in the application binary
|
||||
- Stored in the app's metadata
|
||||
|
||||
2. **Fetching Latest Version**: When checking for updates, electron-updater:
|
||||
- Queries the GitHub Releases API: `https://api.github.com/repos/amiayweb/Hytale-F2P/releases/latest`
|
||||
- Or reads the update metadata file: `https://github.com/amiayweb/Hytale-F2P/releases/download/latest/latest.yml` (or `latest-mac.yml` for macOS)
|
||||
- The metadata file contains:
|
||||
```yaml
|
||||
version: 2.0.3
|
||||
releaseDate: '2024-01-15T10:30:00.000Z'
|
||||
path: Hytale-F2P-Launcher-2.0.3-x64.exe
|
||||
sha512: ...
|
||||
```
|
||||
|
||||
3. **Version Comparison**: electron-updater uses semantic versioning comparison:
|
||||
- Compares the **current version** (from `package.json`) with the **latest version** (from GitHub Releases)
|
||||
- Uses semantic versioning rules: `major.minor.patch` (e.g., `2.0.2` vs `2.0.3`)
|
||||
- An update is available if the remote version is **greater than** the current version
|
||||
- Examples:
|
||||
- Current: `2.0.2` → Remote: `2.0.3` ✅ Update available
|
||||
- Current: `2.0.2` → Remote: `2.0.2` ❌ No update (same version)
|
||||
- Current: `2.0.3` → Remote: `2.0.2` ❌ No update (current is newer)
|
||||
- Current: `2.0.2b` → Remote: `2.0.3` ✅ Update available (prerelease tags are handled)
|
||||
|
||||
4. **Version Format Handling**:
|
||||
- **Semantic versions** (e.g., `1.0.0`, `2.1.3`) are compared numerically
|
||||
- **Prerelease versions** (e.g., `2.0.2b`, `2.0.2-beta`) are compared with special handling
|
||||
- **Non-semantic versions** may cause issues - it's recommended to use semantic versioning
|
||||
|
||||
### Update Metadata Files
|
||||
|
||||
When you build and publish a release, electron-builder generates platform-specific metadata files:
|
||||
|
||||
**Windows/Linux** (`latest.yml`):
|
||||
```yaml
|
||||
version: 2.0.3
|
||||
files:
|
||||
- url: Hytale-F2P-Launcher-2.0.3-x64.exe
|
||||
sha512: abc123...
|
||||
size: 12345678
|
||||
path: Hytale-F2P-Launcher-2.0.3-x64.exe
|
||||
sha512: abc123...
|
||||
releaseDate: '2024-01-15T10:30:00.000Z'
|
||||
```
|
||||
|
||||
**macOS** (`latest-mac.yml`):
|
||||
```yaml
|
||||
version: 2.0.3
|
||||
files:
|
||||
- url: Hytale-F2P-Launcher-2.0.3-arm64-mac.zip
|
||||
sha512: def456...
|
||||
size: 23456789
|
||||
path: Hytale-F2P-Launcher-2.0.3-arm64-mac.zip
|
||||
sha512: def456...
|
||||
releaseDate: '2024-01-15T10:30:00.000Z'
|
||||
```
|
||||
|
||||
These files are:
|
||||
- Automatically generated during build
|
||||
- Uploaded to GitHub Releases
|
||||
- Fetched by electron-updater to check for updates
|
||||
- Used to determine if an update is available and what to download
|
||||
|
||||
### The Check Process in Detail
|
||||
|
||||
When `appUpdater.checkForUpdatesAndNotify()` is called:
|
||||
|
||||
1. **Read Current Version**: Gets version from `app.getVersion()` (which reads from `package.json`)
|
||||
2. **Fetch Update Info**:
|
||||
- Makes HTTP request to GitHub Releases API or reads `latest.yml`
|
||||
- Gets the version number from the metadata
|
||||
3. **Compare Versions**:
|
||||
- Uses semantic versioning comparison (e.g., `semver.gt(remoteVersion, currentVersion)`)
|
||||
- If remote > current: update available
|
||||
- If remote <= current: no update
|
||||
4. **Emit Events**:
|
||||
- `update-available` if newer version found
|
||||
- `update-not-available` if already up to date
|
||||
5. **Download if Available**: If `autoDownload` is enabled, starts downloading automatically
|
||||
|
||||
### Example Flow
|
||||
|
||||
```
|
||||
App Version: 2.0.2 (from package.json)
|
||||
↓
|
||||
Check GitHub Releases API
|
||||
↓
|
||||
Latest Release: 2.0.3
|
||||
↓
|
||||
Compare: 2.0.3 > 2.0.2? YES
|
||||
↓
|
||||
Emit: 'update-available' event
|
||||
↓
|
||||
Download update automatically
|
||||
↓
|
||||
Emit: 'update-downloaded' event
|
||||
↓
|
||||
User can install on next restart
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### AppUpdater Class (`backend/appUpdater.js`)
|
||||
|
||||
The main class that handles all update operations:
|
||||
|
||||
- **`checkForUpdatesAndNotify()`**: Checks for updates and shows a system notification if available
|
||||
- **`checkForUpdates()`**: Manually checks for updates (returns a promise)
|
||||
- **`quitAndInstall()`**: Quits the app and installs the downloaded update
|
||||
|
||||
### Events
|
||||
|
||||
The AppUpdater emits the following events that the UI can listen to:
|
||||
|
||||
- `update-checking`: Update check has started
|
||||
- `update-available`: A new update is available
|
||||
- `update-not-available`: App is up to date
|
||||
- `update-download-progress`: Download progress updates
|
||||
- `update-downloaded`: Update has finished downloading
|
||||
- `update-error`: An error occurred during the update process
|
||||
|
||||
## Configuration
|
||||
|
||||
### Package.json
|
||||
|
||||
The publish configuration in `package.json` tells electron-builder where to publish updates:
|
||||
|
||||
```json
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "amiayweb",
|
||||
"repo": "Hytale-F2P"
|
||||
}
|
||||
```
|
||||
|
||||
This means updates will be fetched from GitHub Releases for the `amiayweb/Hytale-F2P` repository.
|
||||
|
||||
## Publishing Updates
|
||||
|
||||
### For Developers
|
||||
|
||||
1. **Update Version**: Bump the version in `package.json` (e.g., `2.0.2b` → `2.0.3`)
|
||||
|
||||
2. **Build the App**: Run the build command for your platform:
|
||||
```bash
|
||||
npm run build:win # Windows
|
||||
npm run build:mac # macOS
|
||||
npm run build:linux # Linux
|
||||
```
|
||||
|
||||
3. **Publish to GitHub**: When building with electron-builder, it will:
|
||||
- Generate update metadata files (`latest.yml`, `latest-mac.yml`, etc.)
|
||||
- Upload the built files to GitHub Releases (if configured with `GH_TOKEN`)
|
||||
- Make them available for auto-update
|
||||
|
||||
4. **Release on GitHub**: Create a GitHub Release with the new version tag
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **macOS Code Signing**: macOS apps **must** be code-signed for auto-updates to work
|
||||
- **Version Format**: Use semantic versioning (e.g., `1.0.0`, `2.0.1`) for best compatibility
|
||||
- **Update Files**: electron-builder automatically generates the required metadata files (`latest.yml`, etc.)
|
||||
|
||||
## Testing Updates
|
||||
|
||||
### Development Mode
|
||||
|
||||
To test updates during development, create a `dev-app-update.yml` file in the project root:
|
||||
|
||||
```yaml
|
||||
owner: amiayweb
|
||||
repo: Hytale-F2P
|
||||
provider: github
|
||||
```
|
||||
|
||||
Then enable dev mode in the code:
|
||||
```javascript
|
||||
autoUpdater.forceDevUpdateConfig = true;
|
||||
```
|
||||
|
||||
### Local Testing
|
||||
|
||||
For local testing, you can use a local server (like Minio) or a generic HTTP server to host update files.
|
||||
|
||||
## User Experience
|
||||
|
||||
### What Users See
|
||||
|
||||
1. **On Startup**: The app silently checks for updates in the background
|
||||
2. **Update Available**: A notification appears if an update is found
|
||||
3. **Downloading**: Progress bar shows download status
|
||||
4. **Ready to Install**: User is notified when the update is ready
|
||||
5. **Installation**: Update installs on app restart or when user clicks "Install Now"
|
||||
|
||||
### User Actions
|
||||
|
||||
- Users can manually check for updates through the settings/update menu
|
||||
- Users can choose to install immediately or wait until next app launch
|
||||
- Users can continue using the app while updates download in the background
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Updates Not Working
|
||||
|
||||
1. **Check GitHub Releases**: Ensure releases are published on GitHub
|
||||
2. **Check Version**: Make sure the version in `package.json` is higher than the current release
|
||||
3. **Check Logs**: Check the app logs for update-related errors
|
||||
4. **Code Signing (macOS)**: Verify the app is properly code-signed
|
||||
|
||||
### Common Issues
|
||||
|
||||
- **"Update not available"**: Version in `package.json` may not be higher than the current release
|
||||
- **"Download failed"**: Network issues or GitHub API rate limits
|
||||
- **"Installation failed"**: Permissions issue or app is running from an unsupported location
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
- **Windows**: NSIS installer (auto-update supported)
|
||||
- **macOS**: DMG + ZIP (auto-update supported, requires code signing)
|
||||
- **Linux**: AppImage, DEB, RPM, Pacman (auto-update supported)
|
||||
|
||||
### Update Files Generated
|
||||
|
||||
When building, electron-builder generates:
|
||||
- `latest.yml` (Windows/Linux)
|
||||
- `latest-mac.yml` (macOS)
|
||||
- `latest-linux.yml` (Linux)
|
||||
|
||||
These files contain metadata about the latest release and are automatically uploaded to GitHub Releases.
|
||||
|
||||
## References
|
||||
|
||||
- [electron-updater Documentation](https://www.electron.build/auto-update)
|
||||
- [electron-builder Auto Update Guide](https://www.electron.build/auto-update)
|
||||
39
docs/BUILD.md
Normal file
39
docs/BUILD.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Build Instructions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### Build for current platform:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Build for specific platform:
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
npm run build:linux
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
npm run build:mac
|
||||
```
|
||||
|
||||
### Build for all platforms:
|
||||
```bash
|
||||
npm run build:all
|
||||
```
|
||||
|
||||
Built executables will be in the `dist/` directory
|
||||
78
docs/CLEAR-UPDATE-CACHE.md
Normal file
78
docs/CLEAR-UPDATE-CACHE.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Clearing Electron-Updater Cache
|
||||
|
||||
To force electron-updater to re-download an update file, you need to clear the cached download.
|
||||
|
||||
## Quick Method (Terminal)
|
||||
|
||||
### macOS
|
||||
```bash
|
||||
# Remove the entire cache directory
|
||||
rm -rf ~/Library/Caches/hytale-f2p-launcher
|
||||
|
||||
# Or just remove pending downloads
|
||||
rm -rf ~/Library/Caches/hytale-f2p-launcher/pending
|
||||
```
|
||||
|
||||
### Windows
|
||||
```bash
|
||||
# Remove the entire cache directory
|
||||
rmdir /s "%LOCALAPPDATA%\hytale-f2p-launcher-updater"
|
||||
|
||||
# Or just remove pending downloads
|
||||
rmdir /s "%LOCALAPPDATA%\hytale-f2p-launcher-updater\pending"
|
||||
```
|
||||
|
||||
### Linux
|
||||
```bash
|
||||
# Remove the entire cache directory
|
||||
rm -rf ~/.cache/hytale-f2p-launcher-updater
|
||||
|
||||
# Or just remove pending downloads
|
||||
rm -rf ~/.cache/hytale-f2p-launcher-updater/pending
|
||||
```
|
||||
|
||||
## Cache Locations
|
||||
|
||||
electron-updater stores downloaded updates in:
|
||||
|
||||
- **macOS**: `~/Library/Caches/hytale-f2p-launcher/`
|
||||
- **Windows**: `%LOCALAPPDATA%\hytale-f2p-launcher-updater\`
|
||||
- **Linux**: `~/.cache/hytale-f2p-launcher-updater/`
|
||||
|
||||
The cache typically contains:
|
||||
- `pending/` - Downloaded update files waiting to be installed
|
||||
- Metadata files about available updates
|
||||
|
||||
## After Clearing
|
||||
|
||||
After clearing the cache:
|
||||
1. Restart the launcher
|
||||
2. It will check for updates again
|
||||
3. The update will be re-downloaded from scratch
|
||||
|
||||
## Programmatic Method
|
||||
|
||||
You can also clear the cache programmatically by adding this to your code:
|
||||
|
||||
```javascript
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
function clearUpdateCache() {
|
||||
const cacheDir = path.join(
|
||||
os.homedir(),
|
||||
process.platform === 'win32'
|
||||
? 'AppData/Local/hytale-f2p-launcher-updater'
|
||||
: process.platform === 'darwin'
|
||||
? 'Library/Caches/hytale-f2p-launcher'
|
||||
: '.cache/hytale-f2p-launcher-updater'
|
||||
);
|
||||
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
console.log('Update cache cleared');
|
||||
}
|
||||
}
|
||||
```
|
||||
196
docs/TESTING-UPDATES.md
Normal file
196
docs/TESTING-UPDATES.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Testing Auto-Updates
|
||||
|
||||
This guide explains how to test the auto-update system during development.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Test with GitHub Releases (Easiest)
|
||||
|
||||
1. **Set up dev-app-update.yml** (already done):
|
||||
```yaml
|
||||
provider: github
|
||||
owner: amiayweb
|
||||
repo: Hytale-F2P
|
||||
```
|
||||
|
||||
2. **Lower your current version** in `package.json`:
|
||||
- Change version to something lower than what's on GitHub (e.g., `2.0.1` if GitHub has `2.0.3`)
|
||||
|
||||
3. **Run the app in dev mode**:
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
npm start
|
||||
```
|
||||
|
||||
4. **The app will check for updates** 3 seconds after startup
|
||||
- If a newer version exists on GitHub, it will detect it
|
||||
- Check the console logs for update messages
|
||||
|
||||
### Option 2: Test with Local HTTP Server
|
||||
|
||||
For more control, you can set up a local server:
|
||||
|
||||
1. **Create a test update server**:
|
||||
```bash
|
||||
# Create a test directory
|
||||
mkdir -p test-updates
|
||||
cd test-updates
|
||||
```
|
||||
|
||||
2. **Build a test version** with a higher version number:
|
||||
```bash
|
||||
# In package.json, set version to 2.0.4
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. **Copy the generated files** to your test server:
|
||||
- Copy `dist/latest.yml` (or `latest-mac.yml` for macOS)
|
||||
- Copy the built installer/package
|
||||
|
||||
4. **Start a simple HTTP server**:
|
||||
```bash
|
||||
# Using Python
|
||||
python3 -m http.server 8080
|
||||
|
||||
# Or using Node.js http-server
|
||||
npx http-server -p 8080
|
||||
```
|
||||
|
||||
5. **Update dev-app-update.yml** to point to local server:
|
||||
```yaml
|
||||
provider: generic
|
||||
url: http://localhost:8080
|
||||
```
|
||||
|
||||
6. **Run the app** and it will check your local server
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### 1. Prepare Test Environment
|
||||
|
||||
**Current version**: `2.0.3` (in package.json)
|
||||
**Test version**: `2.0.4` (on GitHub or local server)
|
||||
|
||||
### 2. Run the App
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. Watch for Update Events
|
||||
|
||||
The app will automatically check for updates 3 seconds after startup. Watch the console for:
|
||||
|
||||
```
|
||||
Checking for updates...
|
||||
Update available: 2.0.4
|
||||
```
|
||||
|
||||
### 4. Check Console Logs
|
||||
|
||||
Look for these messages:
|
||||
- `Checking for updates...` - Update check started
|
||||
- `Update available: 2.0.4` - New version found
|
||||
- `Download speed: ...` - Download progress
|
||||
- `Update downloaded: 2.0.4` - Download complete
|
||||
|
||||
### 5. Test UI Integration
|
||||
|
||||
The app sends these events to the renderer:
|
||||
- `update-checking`
|
||||
- `update-available` (with version info)
|
||||
- `update-download-progress` (with progress data)
|
||||
- `update-downloaded` (ready to install)
|
||||
|
||||
You can listen to these in your frontend code to show update notifications.
|
||||
|
||||
## Manual Testing
|
||||
|
||||
### Trigger Manual Update Check
|
||||
|
||||
You can also trigger a manual check via IPC:
|
||||
```javascript
|
||||
// In renderer process
|
||||
const result = await window.electronAPI.invoke('check-for-updates');
|
||||
console.log(result);
|
||||
```
|
||||
|
||||
### Install Update
|
||||
|
||||
After an update is downloaded:
|
||||
```javascript
|
||||
// In renderer process
|
||||
await window.electronAPI.invoke('quit-and-install-update');
|
||||
```
|
||||
|
||||
## Testing Scenarios
|
||||
|
||||
### Scenario 1: Update Available
|
||||
1. Set `package.json` version to `2.0.1`
|
||||
2. Ensure GitHub has version `2.0.3` or higher
|
||||
3. Run app → Should detect update
|
||||
|
||||
### Scenario 2: Already Up to Date
|
||||
1. Set `package.json` version to `2.0.3`
|
||||
2. Ensure GitHub has version `2.0.3` or lower
|
||||
3. Run app → Should show "no update available"
|
||||
|
||||
### Scenario 3: Prerelease Version
|
||||
1. Set `package.json` version to `2.0.2b`
|
||||
2. Ensure GitHub has version `2.0.3`
|
||||
3. Run app → Should detect update (prerelease < release)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Update Not Detected
|
||||
|
||||
1. **Check dev-app-update.yml exists** in project root
|
||||
2. **Verify dev mode is enabled** - Check console for "Dev update mode enabled"
|
||||
3. **Check version numbers** - Remote version must be higher than current
|
||||
4. **Check network** - App needs internet to reach GitHub/local server
|
||||
5. **Check logs** - Look for error messages in console
|
||||
|
||||
### Common Errors
|
||||
|
||||
- **"Cannot find module 'electron-updater'"**: Run `npm install`
|
||||
- **"Update check failed"**: Check network connection or GitHub API access
|
||||
- **"No update available"**: Version comparison issue - check versions
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable more verbose logging by checking the console output. The logger will show:
|
||||
- Update check requests
|
||||
- Version comparisons
|
||||
- Download progress
|
||||
- Any errors
|
||||
|
||||
## Testing with Real GitHub Releases
|
||||
|
||||
For the most realistic test:
|
||||
|
||||
1. **Create a test release on GitHub**:
|
||||
- Build the app with version `2.0.4`
|
||||
- Create a GitHub release with tag `v2.0.4`
|
||||
- Upload the built files
|
||||
|
||||
2. **Lower your local version**:
|
||||
- Set `package.json` to `2.0.3`
|
||||
|
||||
3. **Run the app**:
|
||||
- It will check GitHub and find `2.0.4`
|
||||
- Download and install the update
|
||||
|
||||
## Notes
|
||||
|
||||
- **Dev mode only works when app is NOT packaged** (`!app.isPackaged`)
|
||||
- **Production builds** ignore `dev-app-update.yml` and use the built-in `app-update.yml`
|
||||
- **macOS**: Code signing is required for updates to work in production
|
||||
- **Windows**: NSIS installer is required for auto-updates
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once testing is complete:
|
||||
1. Remove or comment out `forceDevUpdateConfig` for production
|
||||
2. Ensure proper code signing for macOS
|
||||
3. Set up CI/CD to automatically publish releases
|
||||
555
main.js
555
main.js
@@ -1,18 +1,46 @@
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
|
||||
const fs = require('fs');
|
||||
const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveChatUsername, loadChatUsername, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, isGameInstalled, uninstallGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched } = require('./backend/launcher');
|
||||
const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveChatUsername, loadChatUsername, saveChatColor, loadChatColor, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, saveDiscordRPC, loadDiscordRPC, saveLanguage, loadLanguage, saveCloseLauncherOnStart, loadCloseLauncherOnStart, isGameInstalled, uninstallGame, repairGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched } = require('./backend/launcher');
|
||||
|
||||
const UpdateManager = require('./backend/updateManager');
|
||||
const logger = require('./backend/logger');
|
||||
const profileManager = require('./backend/managers/profileManager');
|
||||
|
||||
logger.interceptConsole();
|
||||
|
||||
// Single instance lock
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!gotTheLock) {
|
||||
console.log('Another instance is already running. Quitting...');
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', (event, commandLine, workingDirectory) => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mainWindow;
|
||||
let updateManager;
|
||||
let discordRPC = null;
|
||||
|
||||
// Discord Rich Presence setup
|
||||
const DISCORD_CLIENT_ID = '1462244937868513373';
|
||||
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID;
|
||||
|
||||
function initDiscordRPC() {
|
||||
try {
|
||||
// Check if Discord RPC is enabled in settings
|
||||
const rpcEnabled = loadDiscordRPC();
|
||||
if (!rpcEnabled) {
|
||||
console.log('Discord RPC disabled in settings');
|
||||
return;
|
||||
}
|
||||
|
||||
const { Client } = require('discord-rpc');
|
||||
discordRPC = new Client({ transport: 'ipc' });
|
||||
|
||||
@@ -54,14 +82,62 @@ function setDiscordActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDiscordRPC(enabled) {
|
||||
console.log('Toggling Discord RPC:', enabled);
|
||||
|
||||
if (enabled && !discordRPC) {
|
||||
console.log('Initializing Discord RPC...');
|
||||
initDiscordRPC();
|
||||
} else if (!enabled && discordRPC) {
|
||||
try {
|
||||
console.log('Disconnecting Discord RPC...');
|
||||
discordRPC.clearActivity();
|
||||
discordRPC.destroy();
|
||||
discordRPC = null;
|
||||
console.log('Discord RPC disconnected successfully');
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting Discord RPC:', error.message);
|
||||
discordRPC = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createSplashScreen() {
|
||||
const splashWindow = new BrowserWindow({
|
||||
width: 500,
|
||||
height: 350,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
alwaysOnTop: true,
|
||||
resizable: false,
|
||||
skipTaskbar: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true
|
||||
}
|
||||
});
|
||||
|
||||
splashWindow.loadFile('GUI/splash.html');
|
||||
splashWindow.center();
|
||||
|
||||
// close splash after 2.5s , need to implement a files check or whatever. just mock for now
|
||||
setTimeout(() => {
|
||||
splashWindow.close();
|
||||
createWindow();
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
resizable: true,
|
||||
alwaysOnTop: false,
|
||||
backgroundColor: '#090909',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
@@ -73,6 +149,16 @@ function createWindow() {
|
||||
|
||||
mainWindow.loadFile('GUI/index.html');
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
// Cleanup Discord RPC when window is closed
|
||||
mainWindow.on('closed', () => {
|
||||
console.log('Main window closed, cleaning up Discord RPC...');
|
||||
cleanupDiscordRPC();
|
||||
});
|
||||
|
||||
// Initialize Discord Rich Presence
|
||||
initDiscordRPC();
|
||||
|
||||
@@ -84,7 +170,6 @@ function createWindow() {
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
|
||||
mainWindow.webContents.on('devtools-opened', () => {
|
||||
mainWindow.webContents.closeDevTools();
|
||||
});
|
||||
@@ -105,9 +190,20 @@ function createWindow() {
|
||||
if (input.key === 'F5') {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Close application shortcuts
|
||||
const isMac = process.platform === 'darwin';
|
||||
const quitShortcut = (isMac && input.meta && input.key.toLowerCase() === 'q') ||
|
||||
(!isMac && input.control && input.key.toLowerCase() === 'q') ||
|
||||
(!isMac && input.alt && input.key === 'F4');
|
||||
|
||||
if (quitShortcut) {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
mainWindow.webContents.on('context-menu', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
@@ -116,9 +212,50 @@ function createWindow() {
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
createWindow();
|
||||
const packageJson = require('./package.json');
|
||||
console.log('=== HYTALE F2P LAUNCHER STARTED ===');
|
||||
console.log('Launcher version:', packageJson.version);
|
||||
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());
|
||||
|
||||
try {
|
||||
const { loadGpuPreference, detectGpu } = require('./backend/launcher');
|
||||
const savedPreference = loadGpuPreference();
|
||||
if (savedPreference === 'auto') {
|
||||
global.detectedGpu = detectGpu(); // if 'auto' selected = preload GPU detection
|
||||
console.log('GPU auto-detection completed on startup:', global.detectedGpu);
|
||||
} else {
|
||||
console.log('GPU preference is manual, skipping auto-detection');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to preload GPU detection:', error.message);
|
||||
global.detectedGpu = { mode: 'integrated', vendor: 'intel' };
|
||||
}
|
||||
|
||||
|
||||
// Initialize Profile Manager (runs migration if needed)
|
||||
profileManager.init();
|
||||
|
||||
createSplashScreen();
|
||||
|
||||
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...');
|
||||
|
||||
@@ -132,7 +269,19 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const firstLaunchResult = await handleFirstLaunchCheck(progressCallback);
|
||||
const firstLaunchResult = await Promise.race([
|
||||
handleFirstLaunchCheck(progressCallback),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('First launch check timeout')), 12000);
|
||||
})
|
||||
]);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (timeoutReached) {
|
||||
console.log('Timeout already reached, skipping result processing');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('First launch check result:', firstLaunchResult);
|
||||
|
||||
@@ -141,47 +290,71 @@ app.whenReady().then(async () => {
|
||||
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 {
|
||||
mainWindow.webContents.send('lock-play-button', false);
|
||||
unlockPlayButton();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
console.error('Error during first launch check:', error);
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('lock-play-button', false);
|
||||
if (!timeoutReached) {
|
||||
unlockPlayButton();
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Clean up Discord RPC connection
|
||||
function cleanupDiscordRPC() {
|
||||
if (discordRPC) {
|
||||
try {
|
||||
console.log('Cleaning up Discord RPC...');
|
||||
discordRPC.clearActivity();
|
||||
setTimeout(() => {
|
||||
try {
|
||||
discordRPC.destroy();
|
||||
} catch (error) {
|
||||
console.log('Error during final Discord RPC cleanup:', error.message);
|
||||
}
|
||||
}, 100);
|
||||
discordRPC = null;
|
||||
} catch (error) {
|
||||
console.log('Error cleaning up Discord RPC:', error.message);
|
||||
discordRPC = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
app.on('before-quit', () => {
|
||||
console.log('=== LAUNCHER BEFORE QUIT ===');
|
||||
cleanupDiscordRPC();
|
||||
});
|
||||
|
||||
ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath) => {
|
||||
app.on('window-all-closed', () => {
|
||||
console.log('=== LAUNCHER CLOSING ===');
|
||||
|
||||
cleanupDiscordRPC();
|
||||
|
||||
app.quit();
|
||||
});
|
||||
|
||||
|
||||
ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, gpuPreference) => {
|
||||
try {
|
||||
const progressCallback = (message, percent, speed, downloaded, total) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
@@ -196,18 +369,41 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath) =
|
||||
}
|
||||
};
|
||||
|
||||
const result = await launchGameWithVersionCheck(playerName, progressCallback, javaPath, installPath);
|
||||
const result = await launchGameWithVersionCheck(playerName, progressCallback, javaPath, installPath, gpuPreference);
|
||||
|
||||
if (result.success && result.launched) {
|
||||
const closeOnStart = loadCloseLauncherOnStart();
|
||||
if (closeOnStart) {
|
||||
console.log('Close Launcher on start enabled, quitting application...');
|
||||
setTimeout(() => {
|
||||
app.quit();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Launch error:', error);
|
||||
const errorMessage = error.message || error.toString();
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
setTimeout(() => {
|
||||
mainWindow.webContents.send('progress-complete');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('install-game', async (event, playerName, javaPath, installPath) => {
|
||||
try {
|
||||
// Signal installation start
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('installation-start');
|
||||
}
|
||||
|
||||
const progressCallback = (message, percent, speed, downloaded, total) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const data = {
|
||||
@@ -223,10 +419,21 @@ ipcMain.handle('install-game', async (event, playerName, javaPath, installPath)
|
||||
|
||||
const result = await installGame(playerName, progressCallback, javaPath, installPath);
|
||||
|
||||
// Signal installation end
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('installation-end');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Install error:', error);
|
||||
const errorMessage = error.message || error.toString();
|
||||
|
||||
// Signal installation end on error too
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('installation-end');
|
||||
}
|
||||
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
});
|
||||
@@ -246,6 +453,16 @@ ipcMain.handle('save-chat-username', async (event, chatUsername) => {
|
||||
ipcMain.handle('load-chat-username', async () => {
|
||||
return loadChatUsername();
|
||||
});
|
||||
|
||||
ipcMain.handle('save-chat-color', (event, color) => {
|
||||
saveChatColor(color);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('load-chat-color', () => {
|
||||
return loadChatColor();
|
||||
});
|
||||
|
||||
ipcMain.handle('save-java-path', (event, javaPath) => {
|
||||
saveJavaPath(javaPath);
|
||||
return { success: true };
|
||||
@@ -257,6 +474,7 @@ ipcMain.handle('load-java-path', () => {
|
||||
|
||||
ipcMain.handle('save-install-path', (event, installPath) => {
|
||||
saveInstallPath(installPath);
|
||||
logger.updateInstallPath();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -264,7 +482,36 @@ ipcMain.handle('load-install-path', () => {
|
||||
return loadInstallPath();
|
||||
});
|
||||
|
||||
ipcMain.handle('save-discord-rpc', (event, enabled) => {
|
||||
saveDiscordRPC(enabled);
|
||||
toggleDiscordRPC(enabled);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('load-discord-rpc', () => {
|
||||
return loadDiscordRPC();
|
||||
});
|
||||
|
||||
ipcMain.handle('save-language', (event, language) => {
|
||||
saveLanguage(language);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('load-language', () => {
|
||||
return loadLanguage();
|
||||
});
|
||||
|
||||
ipcMain.handle('save-close-launcher', (event, enabled) => {
|
||||
saveCloseLauncherOnStart(enabled);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('load-close-launcher', () => {
|
||||
return loadCloseLauncherOnStart();
|
||||
});
|
||||
|
||||
ipcMain.handle('select-install-path', async () => {
|
||||
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openDirectory'],
|
||||
title: 'Select Installation Folder'
|
||||
@@ -311,20 +558,50 @@ ipcMain.handle('mark-as-launched', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('is-game-installed', () => {
|
||||
return isGameInstalled();
|
||||
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('repair-game', async () => {
|
||||
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 repairGame(progressCallback);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Repair error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-hytale-news', async () => {
|
||||
try {
|
||||
const news = await getHytaleNews();
|
||||
@@ -345,6 +622,23 @@ ipcMain.handle('open-external', async (event, url) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('open-game-location', async () => {
|
||||
try {
|
||||
const { getResolvedAppDir } = require('./backend/launcher');
|
||||
const gameDir = path.join(getResolvedAppDir(), 'release', 'package', 'game');
|
||||
|
||||
if (fs.existsSync(gameDir)) {
|
||||
await shell.openPath(gameDir);
|
||||
return { success: true };
|
||||
} else {
|
||||
throw new Error('Game directory not found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open game location:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('browse-java-path', async () => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const isMac = process.platform === 'darwin';
|
||||
@@ -392,7 +686,10 @@ 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);
|
||||
if (settings.installPath !== undefined) {
|
||||
saveInstallPath(settings.installPath);
|
||||
logger.updateInstallPath();
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Save settings error:', error);
|
||||
@@ -419,13 +716,17 @@ ipcMain.handle('load-settings', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
const { getModsPath, loadInstalledMods, downloadMod, uninstallMod, toggleMod } = require('./backend/launcher');
|
||||
const { getModsPath, loadInstalledMods, downloadMod, uninstallMod, toggleMod, getCurrentUuid, getAllUuidMappings, setUuidForUser, generateNewUuid, deleteUuidForUser, resetCurrentUserUuid } = 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-env-var', async (event, key) => {
|
||||
return process.env[key];
|
||||
});
|
||||
|
||||
ipcMain.handle('get-user-id', async () => {
|
||||
try {
|
||||
const { getOrCreatePlayerId } = require('./backend/launcher');
|
||||
@@ -536,11 +837,10 @@ ipcMain.handle('open-download-page', async () => {
|
||||
await shell.openExternal(updateManager.getDownloadUrl());
|
||||
|
||||
setTimeout(() => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.close();
|
||||
}
|
||||
app.quit();
|
||||
}, 1000);
|
||||
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error opening download page:', error);
|
||||
@@ -552,15 +852,214 @@ ipcMain.handle('get-update-info', async () => {
|
||||
return updateManager.getUpdateInfo();
|
||||
});
|
||||
|
||||
ipcMain.handle('window-close', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.close();
|
||||
ipcMain.handle('get-gpu-info', () => {
|
||||
try {
|
||||
return app.getGPUInfo('complete');
|
||||
} catch (error) {
|
||||
console.error('Error getting GPU info:', error);
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('save-gpu-preference', (event, gpuPreference) => {
|
||||
const { saveGpuPreference } = require('./backend/launcher');
|
||||
saveGpuPreference(gpuPreference);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('load-gpu-preference', () => {
|
||||
const { loadGpuPreference } = require('./backend/launcher');
|
||||
return loadGpuPreference();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-detected-gpu', () => {
|
||||
if (global.detectedGpu) {
|
||||
return global.detectedGpu;
|
||||
}
|
||||
const { detectGpu } = require('./backend/launcher');
|
||||
global.detectedGpu = detectGpu();
|
||||
return global.detectedGpu;
|
||||
});
|
||||
|
||||
ipcMain.handle('window-close', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
|
||||
ipcMain.handle('window-minimize', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.minimize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('window-maximize', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-version', () => {
|
||||
const packageJson = require('./package.json');
|
||||
return packageJson.version;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-log-directory', () => {
|
||||
return logger.getLogDirectory();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-current-uuid', async () => {
|
||||
try {
|
||||
return getCurrentUuid();
|
||||
} catch (error) {
|
||||
console.error('Error getting current UUID:', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-all-uuid-mappings', async () => {
|
||||
try {
|
||||
const mappings = getAllUuidMappings();
|
||||
return Object.entries(mappings).map(([username, uuid]) => ({
|
||||
username,
|
||||
uuid,
|
||||
isCurrent: username === require('./backend/launcher').loadUsername()
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error getting UUID mappings:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('set-uuid-for-user', async (event, username, uuid) => {
|
||||
try {
|
||||
await setUuidForUser(username, uuid);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error setting UUID for user:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('generate-new-uuid', async () => {
|
||||
try {
|
||||
return generateNewUuid();
|
||||
} catch (error) {
|
||||
console.error('Error generating new UUID:', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-uuid-for-user', async (event, username) => {
|
||||
try {
|
||||
const result = deleteUuidForUser(username);
|
||||
return { success: result };
|
||||
} catch (error) {
|
||||
console.error('Error deleting UUID for user:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('reset-current-user-uuid', async () => {
|
||||
try {
|
||||
const newUuid = resetCurrentUserUuid();
|
||||
return { success: true, uuid: newUuid };
|
||||
} catch (error) {
|
||||
console.error('Error resetting current user UUID:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-recent-logs', async (event, maxLines = 100) => {
|
||||
try {
|
||||
const logDir = logger.getLogDirectory();
|
||||
if (!logDir) return null;
|
||||
|
||||
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');
|
||||
|
||||
let result = lines.slice(-maxLines).join('\n');
|
||||
|
||||
if (lines.length > maxLines) {
|
||||
const truncatedMsg = `\n--- ⚠️ LOG TRUNCATED: Showing last ${maxLines} lines of ${lines.length}. Open Logs Folder for full history ---\n\n`;
|
||||
return result + truncatedMsg;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error reading logs:', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
ipcMain.handle('open-logs-folder', async () => {
|
||||
try {
|
||||
const logDir = logger.getLogDirectory();
|
||||
if (logDir && fs.existsSync(logDir)) {
|
||||
await shell.openPath(logDir);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Logs directory not found' };
|
||||
} catch (error) {
|
||||
console.error('Error opening logs folder:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Profile Management IPC
|
||||
ipcMain.handle('profile-create', async (event, name) => {
|
||||
try {
|
||||
return profileManager.createProfile(name);
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('profile-list', async () => {
|
||||
return profileManager.getProfiles();
|
||||
});
|
||||
|
||||
ipcMain.handle('profile-get-active', async () => {
|
||||
return profileManager.getActiveProfile();
|
||||
});
|
||||
|
||||
ipcMain.handle('profile-activate', async (event, id) => {
|
||||
try {
|
||||
return await profileManager.activateProfile(id);
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('profile-delete', async (event, id) => {
|
||||
try {
|
||||
return profileManager.deleteProfile(id);
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('profile-update', async (event, id, updates) => {
|
||||
try {
|
||||
return profileManager.updateProfile(id, updates);
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
5057
package-lock.json
generated
5057
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
61
package.json
61
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hytale-f2p-launcher",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.11",
|
||||
"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",
|
||||
@@ -24,6 +24,17 @@
|
||||
"mod-manager",
|
||||
"chat"
|
||||
],
|
||||
"maintainers": [
|
||||
{
|
||||
"name": "Terromur",
|
||||
"url": "https://github.com/Terromur"
|
||||
},
|
||||
{
|
||||
"name": "Fari Gading",
|
||||
"email": "fazrigading@gmail.com",
|
||||
"url": "https://github.com/fazrigading"
|
||||
}
|
||||
],
|
||||
"author": {
|
||||
"name": "AMIAY",
|
||||
"email": "support@amiay.dev"
|
||||
@@ -37,7 +48,9 @@
|
||||
"adm-zip": "^0.5.10",
|
||||
"axios": "^1.6.0",
|
||||
"discord-rpc": "^4.0.1",
|
||||
"tar": "6.2.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"electron-updater": "^6.7.3",
|
||||
"tar": "^6.2.1",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
@@ -45,7 +58,8 @@
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.hytalef2p.launcher",
|
||||
"productName": "Hytale F2P",
|
||||
"productName": "Hytale F2P Launcher",
|
||||
"artifactName": "${name}_${version}_${arch}.${ext}",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
@@ -54,20 +68,16 @@
|
||||
"preload.js",
|
||||
"backend/**/*",
|
||||
"GUI/**/*",
|
||||
"package.json"
|
||||
"package.json",
|
||||
".env"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": [
|
||||
"x64"
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -78,19 +88,34 @@
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64"
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": [
|
||||
"x64"
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "rpm",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "pacman",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"category": "Game",
|
||||
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support"
|
||||
"category": "Game"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
@@ -115,7 +140,11 @@
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "amiayweb",
|
||||
"repo": "Hytale-F2P"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
51
preload.js
51
preload.js
@@ -1,27 +1,40 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
launchGame: (playerName, javaPath, installPath) => ipcRenderer.invoke('launch-game', playerName, javaPath, installPath),
|
||||
launchGame: (playerName, javaPath, installPath, gpuPreference) => ipcRenderer.invoke('launch-game', playerName, javaPath, installPath, gpuPreference),
|
||||
installGame: (playerName, javaPath, installPath) => ipcRenderer.invoke('install-game', playerName, javaPath, installPath),
|
||||
closeWindow: () => ipcRenderer.invoke('window-close'),
|
||||
minimizeWindow: () => ipcRenderer.invoke('window-minimize'),
|
||||
maximizeWindow: () => ipcRenderer.invoke('window-maximize'),
|
||||
getVersion: () => ipcRenderer.invoke('get-version'),
|
||||
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'),
|
||||
saveChatColor: (chatColor) => ipcRenderer.invoke('save-chat-color', chatColor),
|
||||
loadChatColor: () => ipcRenderer.invoke('load-chat-color'),
|
||||
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'),
|
||||
saveDiscordRPC: (enabled) => ipcRenderer.invoke('save-discord-rpc', enabled),
|
||||
loadDiscordRPC: () => ipcRenderer.invoke('load-discord-rpc'),
|
||||
saveLanguage: (language) => ipcRenderer.invoke('save-language', language),
|
||||
loadLanguage: () => ipcRenderer.invoke('load-language'),
|
||||
saveCloseLauncher: (enabled) => ipcRenderer.invoke('save-close-launcher', enabled),
|
||||
loadCloseLauncher: () => ipcRenderer.invoke('load-close-launcher'),
|
||||
selectInstallPath: () => ipcRenderer.invoke('select-install-path'),
|
||||
browseJavaPath: () => ipcRenderer.invoke('browse-java-path'),
|
||||
isGameInstalled: () => ipcRenderer.invoke('is-game-installed'),
|
||||
uninstallGame: () => ipcRenderer.invoke('uninstall-game'),
|
||||
repairGame: () => ipcRenderer.invoke('repair-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'),
|
||||
getEnvVar: (key) => ipcRenderer.invoke('get-env-var', key),
|
||||
getLocalAppData: () => ipcRenderer.invoke('get-local-app-data'),
|
||||
getModsPath: () => ipcRenderer.invoke('get-mods-path'),
|
||||
loadInstalledMods: (modsPath) => ipcRenderer.invoke('load-installed-mods', modsPath),
|
||||
@@ -33,6 +46,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
onProgressUpdate: (callback) => {
|
||||
ipcRenderer.on('progress-update', (event, data) => callback(data));
|
||||
},
|
||||
onProgressComplete: (callback) => {
|
||||
ipcRenderer.on('progress-complete', () => callback());
|
||||
},
|
||||
onInstallationStart: (callback) => {
|
||||
ipcRenderer.on('installation-start', () => callback());
|
||||
},
|
||||
onInstallationEnd: (callback) => {
|
||||
ipcRenderer.on('installation-end', () => callback());
|
||||
},
|
||||
getUserId: () => ipcRenderer.invoke('get-user-id'),
|
||||
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
|
||||
openDownloadPage: () => ipcRenderer.invoke('open-download-page'),
|
||||
@@ -41,6 +63,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('show-update-popup', (event, data) => callback(data));
|
||||
},
|
||||
|
||||
getGpuInfo: () => ipcRenderer.invoke('get-gpu-info'),
|
||||
saveGpuPreference: (gpuPreference) => ipcRenderer.invoke('save-gpu-preference', gpuPreference),
|
||||
loadGpuPreference: () => ipcRenderer.invoke('load-gpu-preference'),
|
||||
getDetectedGpu: () => ipcRenderer.invoke('get-detected-gpu'),
|
||||
|
||||
acceptFirstLaunchUpdate: (existingGame) => ipcRenderer.invoke('accept-first-launch-update', existingGame),
|
||||
markAsLaunched: () => ipcRenderer.invoke('mark-as-launched'),
|
||||
onFirstLaunchUpdate: (callback) => {
|
||||
@@ -54,5 +81,27 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
},
|
||||
onLockPlayButton: (callback) => {
|
||||
ipcRenderer.on('lock-play-button', (event, locked) => callback(locked));
|
||||
},
|
||||
|
||||
getLogDirectory: () => ipcRenderer.invoke('get-log-directory'),
|
||||
openLogsFolder: () => ipcRenderer.invoke('open-logs-folder'),
|
||||
getRecentLogs: (maxLines) => ipcRenderer.invoke('get-recent-logs', maxLines),
|
||||
|
||||
// UUID Management methods
|
||||
getCurrentUuid: () => ipcRenderer.invoke('get-current-uuid'),
|
||||
getAllUuidMappings: () => ipcRenderer.invoke('get-all-uuid-mappings'),
|
||||
setUuidForUser: (username, uuid) => ipcRenderer.invoke('set-uuid-for-user', username, uuid),
|
||||
generateNewUuid: () => ipcRenderer.invoke('generate-new-uuid'),
|
||||
deleteUuidForUser: (username) => ipcRenderer.invoke('delete-uuid-for-user', username),
|
||||
resetCurrentUserUuid: () => ipcRenderer.invoke('reset-current-user-uuid'),
|
||||
|
||||
// Profile API
|
||||
profile: {
|
||||
create: (name) => ipcRenderer.invoke('profile-create', name),
|
||||
list: () => ipcRenderer.invoke('profile-list'),
|
||||
getActive: () => ipcRenderer.invoke('profile-get-active'),
|
||||
activate: (id) => ipcRenderer.invoke('profile-activate', id),
|
||||
delete: (id) => ipcRenderer.invoke('profile-delete', id),
|
||||
update: (id, updates) => ipcRenderer.invoke('profile-update', id, updates)
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user