mirror of
https://git.sanhost.net/sanasol/hytale-f2p
synced 2026-04-17 02:22:22 -04:00
Compare commits
11 Commits
debug-no-a
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2b60ba3a0 | ||
|
|
0e50ded8e4 | ||
|
|
c5e5ddbcc4 | ||
|
|
e610072fa6 | ||
|
|
a40465cac9 | ||
|
|
57056e5b7a | ||
|
|
fcf041be39 | ||
|
|
d53ac915f3 | ||
|
|
7347910fe9 | ||
|
|
0b861904ba | ||
|
|
ee53911a06 |
2
.github/CODE_OF_CONDUCT.md
vendored
2
.github/CODE_OF_CONDUCT.md
vendored
@@ -37,7 +37,7 @@ This Code of Conduct applies within all community spaces, and also applies when
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [Community Chat, message Founders/Devs](https://chat.sanhost.net/invite/Tfz4jCK4).
|
||||
<!-- Discord: https://discord.gg/Fhbb9Yk5WW --> All complaints will be reviewed and investigated promptly and fairly.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
|
||||
4
.github/CONTRIBUTING.md
vendored
4
.github/CONTRIBUTING.md
vendored
@@ -1,3 +1,5 @@
|
||||
> **Note:** This project has been superseded by [F2P Evo](https://git.sanhost.net/sanasol/f2p-evo). Please contribute to the new project instead.
|
||||
|
||||
# Contributing to Hytale F2P
|
||||
|
||||
Thank you for your interest in contributing to Hytale F2P! We welcome contributions from everyone. By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
@@ -65,6 +67,6 @@ Thank you for your interest in contributing to Hytale F2P! We welcome contributi
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about contributing, feel free to ask in our [Discussions](https://github.com/your-username/hytale-f2p/discussions) or create a [Support Request](.github/ISSUE_TEMPLATE/support_request.yml).
|
||||
If you have questions about contributing, join our [Community Chat](https://chat.sanhost.net/invite/Tfz4jCK4) or [TG Group](https://t.me/sanhostnet).
|
||||
|
||||
Thank you for contributing to Hytale F2P!
|
||||
212
GUI/index.html
212
GUI/index.html
@@ -24,7 +24,7 @@
|
||||
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">
|
||||
<div class="flex w-full h-screen relative z-10" style="isolation: isolate">
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<img src="./icon.png" alt="Hytale Logo" />
|
||||
@@ -44,10 +44,6 @@
|
||||
<i class="fas fa-box"></i>
|
||||
<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" data-i18n="nav.news">News</span>
|
||||
</div>
|
||||
<div class="nav-item" data-page="settings">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span class="nav-tooltip" data-i18n="nav.settings">Settings</span>
|
||||
@@ -56,9 +52,12 @@
|
||||
<i class="fas fa-terminal"></i>
|
||||
<span class="nav-tooltip">Logs</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="openDiscordExternal()">
|
||||
<i class="fas fa-comments"></i>
|
||||
<span class="nav-tooltip">Community Chat</span>
|
||||
<div class="nav-item" id="matchaNavItem">
|
||||
<i class="fas fa-comments" id="matchaNavIcon"></i>
|
||||
<img id="matchaNavAvatar" class="matcha-nav-avatar" style="display:none" />
|
||||
<span class="matcha-nav-status" id="matchaNavStatus" style="display:none"></span>
|
||||
<span class="nav-tooltip">Matcha!</span>
|
||||
<span id="matchaUnreadBadge" class="matcha-unread-badge" style="display:none"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,6 +76,7 @@
|
||||
<button class="identity-btn" onclick="toggleIdentityDropdown()">
|
||||
<i class="fas fa-id-badge"></i>
|
||||
<span id="currentIdentityName">Player</span>
|
||||
<i id="passwordShieldIcon" class="fas fa-unlock password-shield unprotected" data-tooltip="Click to protect identity" onclick="event.stopPropagation(); openPasswordModal()"></i>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="identity-dropdown" id="identityDropdown">
|
||||
@@ -218,36 +218,38 @@
|
||||
<span data-i18n="play.playButton">PLAY HYTALE</span>
|
||||
</button>
|
||||
|
||||
<div style="display: flex; justify-content: center; align-items: center; gap: 8px; margin-top: 12px; font-size: 12px;">
|
||||
<span style="color: #93a3b8;">Telegram:</span>
|
||||
<a href="#" onclick="window.electronAPI?.openExternal('https://t.me/sanhostnet'); return false;" style="color: #93a3b8; text-decoration: none; display: flex; align-items: center; gap: 4px; transition: color 0.2s;" onmouseover="this.style.color='#60a5fa'" onmouseout="this.style.color='#93a3b8'">
|
||||
<i class="fas fa-users"></i> Group
|
||||
<div id="game-info-bar" class="game-info-bar">
|
||||
<span id="game-version-info" class="game-info-item game-info-loading">loading...</span>
|
||||
<span class="game-info-sep"></span>
|
||||
<span id="game-branch-info" class="game-info-item game-info-loading">...</span>
|
||||
<span id="game-last-played" class="game-info-item" style="display: none;"></span>
|
||||
</div>
|
||||
|
||||
<div class="community-links">
|
||||
<a href="#" class="community-link" onclick="openDiscordExternal(); return false;" title="Community Chat">
|
||||
<i class="fas fa-comments" style="color: #22c55e;"></i>
|
||||
<span>Chat</span>
|
||||
</a>
|
||||
<span style="color: #4b5563;">|</span>
|
||||
<a href="#" onclick="window.electronAPI?.openExternal('https://t.me/hf2p_og'); return false;" style="color: #93a3b8; text-decoration: none; display: flex; align-items: center; gap: 4px; transition: color 0.2s;" onmouseover="this.style.color='#60a5fa'" onmouseout="this.style.color='#93a3b8'">
|
||||
<i class="fab fa-telegram"></i> Channel
|
||||
<a href="#" class="community-link" onclick="window.electronAPI?.openExternal('https://discord.gg/8WVU24XshK'); return false;" title="Discord">
|
||||
<i class="fab fa-discord" style="color: #5865F2;"></i>
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
<span style="color: #4b5563;">|</span>
|
||||
<a href="#" onclick="openDiscordExternal(); return false;" style="color: #93a3b8; text-decoration: none; display: flex; align-items: center; gap: 4px; transition: color 0.2s;" onmouseover="this.style.color='#60a5fa'" onmouseout="this.style.color='#93a3b8'">
|
||||
<i class="fas fa-comments"></i> Community Chat
|
||||
<a href="#" class="community-link" onclick="window.electronAPI?.openExternal('https://t.me/hf2p_og'); return false;" title="Telegram Channel">
|
||||
<i class="fab fa-telegram" style="color: #26A5E4;"></i>
|
||||
<span>Channel</span>
|
||||
</a>
|
||||
<a href="#" class="community-link" onclick="window.electronAPI?.openExternal('https://t.me/sanhostnet'); return false;" title="Telegram Group">
|
||||
<i class="fab fa-telegram" style="color: #26A5E4;"></i>
|
||||
<span>Group</span>
|
||||
</a>
|
||||
<a href="#" class="community-link" onclick="window.electronAPI?.openExternal('https://git.sanhost.net/sanasol/hytale-f2p'); return false;" title="Source Code">
|
||||
<i class="fab fa-git-alt" style="color: #e2e8f0;"></i>
|
||||
<span>Source</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="news-section">
|
||||
<div class="news-header">
|
||||
<h2 class="news-title">
|
||||
<i class="fas fa-star mr-2"></i>
|
||||
<span data-i18n="play.latestNews">LATEST NEWS</span>
|
||||
</h2>
|
||||
<button class="view-all-btn" onclick="navigateToPage('news')">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="featured-page" class="page">
|
||||
@@ -300,15 +302,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="news-page" class="page">
|
||||
<div class="news-header">
|
||||
<h2 class="news-title">
|
||||
<i class="fas fa-newspaper mr-2"></i>
|
||||
<span data-i18n="news.title">ALL NEWS</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div id="allNewsGrid" class="news-grid-full"></div>
|
||||
</div>
|
||||
|
||||
<div id="settings-page" class="page">
|
||||
<div class="settings-container">
|
||||
@@ -697,6 +690,29 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Matcha! Panel -->
|
||||
<div id="matchaPanelBackdrop" class="matcha-panel-backdrop"></div>
|
||||
<div id="matchaPanel" class="matcha-panel">
|
||||
<div class="matcha-panel-header">
|
||||
<div id="matchaPanelHeaderContent" class="matcha-panel-header-content">
|
||||
<span class="matcha-header-title">Matcha!</span>
|
||||
</div>
|
||||
<button class="matcha-panel-close" id="matchaPanelCloseBtn">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="matchaPanelBody" class="matcha-panel-body"></div>
|
||||
</div>
|
||||
|
||||
<!-- Matcha User Profile Popup -->
|
||||
<div id="matchaUserProfileOverlay" class="matcha-user-profile-overlay" style="display:none">
|
||||
<div class="matcha-user-profile-card" id="matchaUserProfileCard">
|
||||
<div class="matcha-user-profile-body" id="matchaUserProfileBody">
|
||||
<div class="matcha-loading"><i class="fas fa-spinner fa-spin"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="myModsModal" class="mods-modal">
|
||||
<div class="mods-modal-content">
|
||||
<div class="mods-modal-header">
|
||||
@@ -830,6 +846,41 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uuid-advanced-section" style="margin-top: 12px;">
|
||||
<button id="passwordSectionToggle" class="uuid-advanced-toggle">
|
||||
<i class="fas fa-chevron-right uuid-advanced-chevron"></i>
|
||||
<span>Password Protection</span>
|
||||
</button>
|
||||
<div id="passwordSectionContent" class="uuid-advanced-content" style="display: none;">
|
||||
<h3 class="uuid-section-title">Protect Your Identity</h3>
|
||||
<p class="uuid-custom-hint" style="margin-bottom: 12px;">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<span>Set a password to prevent others from using your UUID</span>
|
||||
</p>
|
||||
<div id="passwordStatusMsg" class="uuid-custom-hint" style="margin-bottom: 8px; color: #93a3b8;"></div>
|
||||
<div id="passwordSetForm">
|
||||
<div class="uuid-custom-form" style="margin-bottom: 8px;">
|
||||
<input type="password" id="currentPasswordInput" class="uuid-input"
|
||||
placeholder="Current password" style="display: none;" />
|
||||
</div>
|
||||
<div class="uuid-custom-form" style="margin-bottom: 8px;">
|
||||
<input type="password" id="newPasswordInput" class="uuid-input"
|
||||
placeholder="New password (min 6 chars)" />
|
||||
</div>
|
||||
<div class="uuid-custom-form">
|
||||
<button id="setPasswordBtn" class="uuid-set-btn" onclick="handleSetPassword()">
|
||||
<i class="fas fa-lock"></i>
|
||||
<span>Set Password</span>
|
||||
</button>
|
||||
<button id="removePasswordBtn" class="uuid-cancel-btn" onclick="handleRemovePassword()" style="display: none;">
|
||||
<i class="fas fa-unlock"></i>
|
||||
<span>Remove Password</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -861,36 +912,95 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="version-display-bottom">
|
||||
<!-- Password Protection Modal -->
|
||||
<div id="passwordModal" class="uuid-modal" style="display: none;">
|
||||
<div class="uuid-modal-content" style="max-width: 420px;">
|
||||
<div class="uuid-modal-header">
|
||||
<h2 class="uuid-modal-title">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
<span>Identity Protection</span>
|
||||
</h2>
|
||||
<button class="modal-close-btn" onclick="closePasswordModal()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="uuid-modal-body" style="padding: 16px 20px;">
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px; padding: 12px; background: rgba(255,255,255,0.03); border-radius: 8px; border: 1px solid rgba(255,255,255,0.06);">
|
||||
<i class="fas fa-user" style="color: #22c55e; font-size: 1.2em;"></i>
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div id="pwModalName" style="font-weight: 600; font-size: 1em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">Player</div>
|
||||
<div id="pwModalUuid" style="font-size: 0.7em; color: #6b7280; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"></div>
|
||||
</div>
|
||||
<div id="pwModalStatusBadge" style="flex-shrink:0;"></div>
|
||||
</div>
|
||||
|
||||
<div id="pwModalStatusText" style="margin-bottom: 14px; font-size: 0.85em; color: #93a3b8; text-align: center;"></div>
|
||||
|
||||
<div id="pwModalSetForm">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<input type="password" id="pwModalCurrentPassword" class="uuid-input"
|
||||
placeholder="Current password" style="display: none; width: 100%;" />
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<input type="password" id="pwModalNewPassword" class="uuid-input"
|
||||
placeholder="New password (min 6 chars)" style="width: 100%;" />
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button id="pwModalSetBtn" class="uuid-set-btn" style="flex:1;" onclick="handlePasswordModalSet()">
|
||||
<i class="fas fa-lock"></i>
|
||||
<span>Set Password</span>
|
||||
</button>
|
||||
<button id="pwModalRemoveBtn" class="uuid-cancel-btn" style="display: none;" onclick="handlePasswordModalRemove()">
|
||||
<i class="fas fa-unlock"></i>
|
||||
<span>Remove</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pwModalUsernameInfo" style="margin-top: 14px; padding: 10px; background: rgba(34,197,94,0.06); border-radius: 6px; border: 1px solid rgba(34,197,94,0.15); font-size: 0.8em; color: #93a3b8; display: none;">
|
||||
<i class="fas fa-info-circle" style="color: #22c55e;"></i>
|
||||
<span>Setting a password also reserves your username — no one else can use it.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="version-display-bottom" onclick="window.electronAPI?.openExternal('https://git.sanhost.net/sanasol/hytale-f2p/releases')" style="cursor: pointer;" title="View releases">
|
||||
<i class="fas fa-code-branch"></i>
|
||||
<span id="launcherVersion"></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"
|
||||
<span>Made by <a href="#" onclick="window.electronAPI?.openExternal('https://github.com/amiayweb'); return false;"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors">@amiayweb</a> & <a
|
||||
href="https://github.com/Relyz1993" target="_blank"
|
||||
href="#" onclick="window.electronAPI?.openExternal('https://github.com/Relyz1993'); return false;"
|
||||
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"
|
||||
<a href="#" onclick="window.electronAPI?.openExternal('https://github.com/chasem-dev'); return false;"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors">@chasem-dev</a>,
|
||||
<a href="https://github.com/crimera" target="_blank"
|
||||
<a href="#" onclick="window.electronAPI?.openExternal('https://github.com/crimera'); return false;"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors">@crimera</a>,
|
||||
<a href="https://github.com/sanasol" target="_blank"
|
||||
<a href="#" onclick="window.electronAPI?.openExternal('https://github.com/sanasol'); return false;"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors">@sanasol</a>,
|
||||
<a href="https://github.com/Terromur" target="_blank"
|
||||
<a href="#" onclick="window.electronAPI?.openExternal('https://github.com/Terromur'); return false;"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors">@terromur</a>,
|
||||
<a href="https://github.com/ericiskoolbeans" target="_blank"
|
||||
<a href="#" onclick="window.electronAPI?.openExternal('https://github.com/ericiskoolbeans'); return false;"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors">@ericiskoolbeans</a>,
|
||||
<a href="https://github.com/fazrigading" target="_blank"
|
||||
<a href="#" onclick="window.electronAPI?.openExternal('https://github.com/fazrigading'); return false;"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors">@fazrigading</a>,
|
||||
<a href="https://github.com/Rahul-Sahani04" target="_blank"
|
||||
<a href="#" onclick="window.electronAPI?.openExternal('https://github.com/Rahul-Sahani04'); return false;"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors">@Rahul-Sahani04</a>,
|
||||
<a href="https://github.com/xSamiVS" target="_blank"
|
||||
<a href="#" onclick="window.electronAPI?.openExternal('https://github.com/xSamiVS'); return false;"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors">@xSamiVS</a>
|
||||
</span>
|
||||
<span class="mx-2">|</span>
|
||||
<span>Social powered by <a href="#" onclick="window.electronAPI?.openExternal('https://github.com/vZylev/Butter-Launcher'); return false;"
|
||||
class="text-purple-400 hover:text-purple-300 transition-colors">Butter Launcher</a> &
|
||||
<a href="#" onclick="window.electronAPI?.openExternal('https://butterlauncher.tech/'); return false;"
|
||||
class="text-purple-400 hover:text-purple-300 transition-colors">Matcha!</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -169,6 +169,16 @@ window.switchProfile = async (id) => {
|
||||
// Refresh UI
|
||||
await loadProfiles();
|
||||
|
||||
// Refresh branch radio buttons in settings
|
||||
if (window.SettingsAPI?.reloadBranch) {
|
||||
await window.SettingsAPI.reloadBranch();
|
||||
}
|
||||
|
||||
// Refresh game info bar on play page
|
||||
if (window.LauncherUI?.loadGameInfoBar) {
|
||||
window.LauncherUI.loadGameInfoBar();
|
||||
}
|
||||
|
||||
// Refresh Mods
|
||||
if (window.modsManager) {
|
||||
if (window.modsManager.loadInstalledMods) await window.modsManager.loadInstalledMods();
|
||||
@@ -193,7 +203,8 @@ window.switchProfile = async (id) => {
|
||||
};
|
||||
|
||||
export async function launch() {
|
||||
if (isDownloading || (playBtn && playBtn.disabled)) return;
|
||||
const btn = homePlayBtn || playBtn;
|
||||
if (isDownloading || (btn && btn.disabled)) return;
|
||||
|
||||
// ==========================================================================
|
||||
// STEP 1: Check launch readiness from backend (single source of truth)
|
||||
@@ -271,11 +282,7 @@ export async function launch() {
|
||||
// STEP 3: Start launch process
|
||||
// ==========================================================================
|
||||
if (window.LauncherUI) window.LauncherUI.showProgress();
|
||||
isDownloading = true;
|
||||
if (playBtn) {
|
||||
playBtn.disabled = true;
|
||||
playText.textContent = 'LAUNCHING...';
|
||||
}
|
||||
lockPlayButton('LAUNCHING...');
|
||||
|
||||
try {
|
||||
const startingMsg = window.i18n ? window.i18n.t('progress.startingGame') : 'Starting game...';
|
||||
@@ -285,20 +292,92 @@ export async function launch() {
|
||||
// Pass playerName from config - backend will validate again
|
||||
const result = await window.electronAPI.launchGame(playerName, javaPath, '', gpuPreference);
|
||||
|
||||
isDownloading = false;
|
||||
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.hideProgress();
|
||||
if (result.usernameTaken) {
|
||||
// Username reserved by another player
|
||||
isDownloading = false;
|
||||
if (window.LauncherUI) window.LauncherUI.hideProgress();
|
||||
resetPlayButton();
|
||||
if (window.LauncherUI && window.LauncherUI.showError) {
|
||||
window.LauncherUI.showError('This username is reserved by another player. Please change your player name in Identity settings.');
|
||||
} else {
|
||||
showNotification('This username is reserved by another player. Please change your player name.', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.nameLocked) {
|
||||
// UUID is password-protected and locked to a specific name
|
||||
isDownloading = false;
|
||||
if (window.LauncherUI) window.LauncherUI.hideProgress();
|
||||
resetPlayButton();
|
||||
const msg = result.registeredName
|
||||
? `This UUID is locked to username "${result.registeredName}". Change your identity name to "${result.registeredName}" in Settings.`
|
||||
: 'This UUID is locked to a different username. Check your identity settings.';
|
||||
if (window.LauncherUI && window.LauncherUI.showError) {
|
||||
window.LauncherUI.showError(msg);
|
||||
} else {
|
||||
showNotification(msg, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.passwordRequired) {
|
||||
// Check for saved password first
|
||||
let savedPw = null;
|
||||
try {
|
||||
const cfg = await window.electronAPI.loadConfig();
|
||||
const uuid = result.uuid || '';
|
||||
savedPw = cfg && cfg.savedPasswords && cfg.savedPasswords[uuid] ? cfg.savedPasswords[uuid] : null;
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
if (savedPw) {
|
||||
// Try saved password silently
|
||||
if (window.LauncherUI) window.LauncherUI.showProgress();
|
||||
lockPlayButton('LAUNCHING...');
|
||||
const autoResult = await window.electronAPI.launchGameWithPassword(playerName, javaPath, '', gpuPreference, savedPw);
|
||||
if (autoResult.success) {
|
||||
isDownloading = false;
|
||||
if (window.LauncherUI) window.LauncherUI.hideProgress();
|
||||
resetPlayButton();
|
||||
if (window.electronAPI.minimizeWindow) setTimeout(() => { window.electronAPI.minimizeWindow(); }, 500);
|
||||
return;
|
||||
}
|
||||
// Saved password failed — clear it and show popup
|
||||
isDownloading = false;
|
||||
if (window.LauncherUI) window.LauncherUI.hideProgress();
|
||||
resetPlayButton();
|
||||
try {
|
||||
const cfg2 = await window.electronAPI.loadConfig();
|
||||
const sp = cfg2.savedPasswords || {};
|
||||
delete sp[result.uuid || ''];
|
||||
await window.electronAPI.saveConfig({ savedPasswords: sp });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// Show interactive password dialog
|
||||
const launchResult = await promptForPasswordAndLaunch(playerName, javaPath, gpuPreference, result.uuid);
|
||||
if (launchResult && launchResult.success) {
|
||||
if (window.electronAPI.minimizeWindow) setTimeout(() => { window.electronAPI.minimizeWindow(); }, 500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
resetPlayButton();
|
||||
|
||||
if (result.success) {
|
||||
// Keep button locked so user can't double-launch
|
||||
if (window.LauncherUI) window.LauncherUI.hideProgress();
|
||||
lockPlayButton('GAME RUNNING');
|
||||
setTimeout(() => {
|
||||
resetPlayButton();
|
||||
}, 10000); // Reset after 10s (game should be visible by then)
|
||||
if (window.electronAPI.minimizeWindow) {
|
||||
setTimeout(() => {
|
||||
window.electronAPI.minimizeWindow();
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
isDownloading = false;
|
||||
if (window.LauncherUI) window.LauncherUI.hideProgress();
|
||||
resetPlayButton();
|
||||
console.error('[Launcher] Launch failed:', result.error);
|
||||
|
||||
// Handle specific error cases
|
||||
@@ -354,6 +433,187 @@ export async function launch() {
|
||||
}
|
||||
}
|
||||
|
||||
function promptForPasswordAndLaunch(playerName, javaPath, gpuPreference, uuid) {
|
||||
return new Promise((resolve) => {
|
||||
// Remove any existing password prompt
|
||||
const existing = document.querySelector('.custom-confirm-modal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'custom-confirm-modal';
|
||||
overlay.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;
|
||||
`;
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.style.cssText = `
|
||||
background: #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
min-width: 380px;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
`;
|
||||
|
||||
dialog.innerHTML = `
|
||||
<div style="padding: 20px 24px; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||
<div style="display: flex; align-items: center; gap: 10px; color: #f59e0b;">
|
||||
<i class="fas fa-lock" style="font-size: 20px;"></i>
|
||||
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 600; color: #e5e7eb;">Password Required</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 20px 24px;">
|
||||
<p style="margin: 0 0 12px 0; color: #9ca3af; font-size: 0.9rem; line-height: 1.5;">This identity is password-protected. Enter your password to continue.</p>
|
||||
<div id="pwErrorMsg" style="display: none; margin-bottom: 12px; padding: 8px 12px; background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #f87171; font-size: 0.85rem;"></div>
|
||||
<input type="password" id="launchPasswordInput" style="
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 14px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 8px;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
" placeholder="Password" autofocus />
|
||||
<label style="display: flex; align-items: center; gap: 8px; margin-top: 12px; cursor: pointer; color: #9ca3af; font-size: 0.85rem; user-select: none;">
|
||||
<input type="checkbox" id="pwRememberCheck" style="accent-color: #f59e0b; width: 16px; height: 16px; cursor: pointer;" />
|
||||
Remember password
|
||||
</label>
|
||||
</div>
|
||||
<div style="padding: 16px 24px; display: flex; gap: 10px; justify-content: flex-end; border-top: 1px solid rgba(255,255,255,0.1);">
|
||||
<button id="pwCancelBtn" style="
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border: 1px solid rgba(156,163,175,0.3);
|
||||
padding: 9px 18px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
">Cancel</button>
|
||||
<button id="pwConfirmBtn" style="
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 9px 18px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
">Login</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const input = overlay.querySelector('#launchPasswordInput');
|
||||
const confirmBtn = overlay.querySelector('#pwConfirmBtn');
|
||||
const cancelBtn = overlay.querySelector('#pwCancelBtn');
|
||||
const errorMsg = overlay.querySelector('#pwErrorMsg');
|
||||
const rememberCheck = overlay.querySelector('#pwRememberCheck');
|
||||
|
||||
let busy = false;
|
||||
|
||||
const close = (result) => {
|
||||
overlay.remove();
|
||||
isDownloading = false;
|
||||
if (window.LauncherUI) window.LauncherUI.hideProgress();
|
||||
resetPlayButton();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const showError = (msg) => {
|
||||
errorMsg.textContent = msg;
|
||||
errorMsg.style.display = 'block';
|
||||
input.style.borderColor = 'rgba(239,68,68,0.5)';
|
||||
input.value = '';
|
||||
input.focus();
|
||||
};
|
||||
|
||||
const tryLogin = async () => {
|
||||
const password = input.value;
|
||||
if (!password) {
|
||||
showError('Please enter your password.');
|
||||
return;
|
||||
}
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
|
||||
// Show loading state
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Logging in...';
|
||||
errorMsg.style.display = 'none';
|
||||
input.style.borderColor = 'rgba(255,255,255,0.15)';
|
||||
|
||||
try {
|
||||
if (window.LauncherUI) window.LauncherUI.showProgress();
|
||||
lockPlayButton('LAUNCHING...');
|
||||
|
||||
const result = await window.electronAPI.launchGameWithPassword(playerName, javaPath, '', gpuPreference, password);
|
||||
|
||||
if (result.success) {
|
||||
// Save password if "Remember" checked
|
||||
if (rememberCheck.checked && uuid) {
|
||||
try {
|
||||
const cfg = await window.electronAPI.loadConfig();
|
||||
const sp = cfg.savedPasswords || {};
|
||||
sp[uuid] = password;
|
||||
await window.electronAPI.saveConfig({ savedPasswords: sp });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
overlay.remove();
|
||||
isDownloading = false;
|
||||
if (window.LauncherUI) window.LauncherUI.hideProgress();
|
||||
resetPlayButton();
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrong password
|
||||
if (result.passwordRequired) {
|
||||
showError(result.error || 'Incorrect password. Please try again.');
|
||||
} else {
|
||||
showError(result.error || 'Launch failed.');
|
||||
}
|
||||
|
||||
isDownloading = false;
|
||||
if (window.LauncherUI) window.LauncherUI.hideProgress();
|
||||
resetPlayButton();
|
||||
} catch (err) {
|
||||
showError(err.message || 'An error occurred.');
|
||||
isDownloading = false;
|
||||
if (window.LauncherUI) window.LauncherUI.hideProgress();
|
||||
resetPlayButton();
|
||||
} finally {
|
||||
busy = false;
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Login';
|
||||
}
|
||||
};
|
||||
|
||||
confirmBtn.addEventListener('click', tryLogin);
|
||||
cancelBtn.addEventListener('click', () => close(null));
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') tryLogin();
|
||||
if (e.key === 'Escape') close(null);
|
||||
});
|
||||
|
||||
setTimeout(() => input.focus(), 100);
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -589,9 +849,21 @@ async function performRepair() {
|
||||
|
||||
function resetPlayButton() {
|
||||
isDownloading = false;
|
||||
if (playBtn) {
|
||||
playBtn.disabled = false;
|
||||
playText.textContent = window.i18n ? window.i18n.t('play.play') : 'PLAY';
|
||||
const btn = homePlayBtn || playBtn;
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
const textEl = btn.querySelector('span');
|
||||
if (textEl) textEl.textContent = window.i18n ? window.i18n.t('play.playButton') : 'PLAY HYTALE';
|
||||
}
|
||||
}
|
||||
|
||||
function lockPlayButton(text) {
|
||||
isDownloading = true;
|
||||
const btn = homePlayBtn || playBtn;
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
const textEl = btn.querySelector('span');
|
||||
if (textEl) textEl.textContent = text || 'LAUNCHING...';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,7 +984,7 @@ async function loadIdentities() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderIdentityList(mappings, currentUsername) {
|
||||
async function renderIdentityList(mappings, currentUsername) {
|
||||
const list = document.getElementById('identityList');
|
||||
if (!list) return;
|
||||
|
||||
@@ -721,13 +993,31 @@ function renderIdentityList(mappings, currentUsername) {
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = mappings.map(m => {
|
||||
// Check password status for all identities in parallel
|
||||
const statusChecks = mappings.map(async m => {
|
||||
try {
|
||||
if (m.uuid && window.electronAPI?.checkPasswordStatus) {
|
||||
const s = await window.electronAPI.checkPasswordStatus(m.uuid);
|
||||
return s?.hasPassword || false;
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
});
|
||||
const statuses = await Promise.all(statusChecks);
|
||||
|
||||
list.innerHTML = mappings.map((m, i) => {
|
||||
const safe = escapeHtml(m.username);
|
||||
const isActive = m.username === currentUsername;
|
||||
const hasPassword = statuses[i];
|
||||
const pwBadge = hasPassword
|
||||
? '<span class="pw-badge locked"><i class="fas fa-lock"></i></span>'
|
||||
: '<span class="pw-badge unlocked"><i class="fas fa-unlock"></i></span>';
|
||||
return `
|
||||
<div class="identity-item ${m.username === currentUsername ? 'active' : ''}"
|
||||
<div class="identity-item ${isActive ? 'active' : ''}"
|
||||
onclick="switchIdentity('${safe.replace(/'/g, "'")}')">
|
||||
<span>${safe}</span>
|
||||
${m.username === currentUsername ? '<i class="fas fa-check ml-auto"></i>' : ''}
|
||||
${pwBadge}
|
||||
${isActive ? '<i class="fas fa-check" style="margin-left:4px;"></i>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -774,6 +1064,9 @@ window.switchIdentity = async (username) => {
|
||||
if (settingsInput) settingsInput.value = username;
|
||||
if (window.loadCurrentUuid) window.loadCurrentUuid();
|
||||
|
||||
// Update password shield icon for new identity
|
||||
if (window.updatePasswordShieldIcon) window.updatePasswordShieldIcon();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to switch identity:', error);
|
||||
}
|
||||
|
||||
1920
GUI/js/matcha.js
Normal file
1920
GUI/js/matcha.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
import './ui.js';
|
||||
import './install.js';
|
||||
import './launcher.js';
|
||||
import './news.js';
|
||||
import { initModsManager } from './mods.js';
|
||||
import './players.js';
|
||||
import './settings.js';
|
||||
import './logs.js';
|
||||
import { initMatcha } from './matcha.js';
|
||||
|
||||
let i18nInitialized = false;
|
||||
(async () => {
|
||||
@@ -16,10 +16,12 @@ let i18nInitialized = false;
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
updateLanguageSelector();
|
||||
initModsManager();
|
||||
initMatcha();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateLanguageSelector();
|
||||
initModsManager();
|
||||
initMatcha();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -515,8 +515,9 @@ async function savePlayerName() {
|
||||
// Also refresh the UUID list to update which entry is marked as current
|
||||
await loadAllUuids();
|
||||
|
||||
// Refresh header identity dropdown
|
||||
// Refresh header identity dropdown + shield icon
|
||||
if (window.loadIdentities) window.loadIdentities();
|
||||
updatePasswordShieldIcon();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving player name:', error);
|
||||
@@ -746,6 +747,7 @@ async function performRegenerateUuid() {
|
||||
await loadAllUuids();
|
||||
}
|
||||
if (window.loadIdentities) window.loadIdentities();
|
||||
updatePasswordShieldIcon();
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to generate new UUID');
|
||||
}
|
||||
@@ -912,18 +914,61 @@ async function confirmAddIdentity() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.electronAPI && window.electronAPI.setUuidForUser) {
|
||||
const result = await window.electronAPI.setUuidForUser(username, uuid);
|
||||
if (result.success) {
|
||||
const msg = window.i18n ? window.i18n.t('notifications.identityAdded') : 'Identity added successfully!';
|
||||
showNotification(msg, 'success');
|
||||
hideAddIdentityForm();
|
||||
await loadAllUuids();
|
||||
if (window.loadIdentities) window.loadIdentities();
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to add identity');
|
||||
// Check if name already exists locally
|
||||
if (window.electronAPI && window.electronAPI.getAllUuidMappings) {
|
||||
const mappings = await window.electronAPI.getAllUuidMappings();
|
||||
const existing = mappings.find(m => m.username.toLowerCase() === username.toLowerCase());
|
||||
if (existing) {
|
||||
showNotification(`Identity "${existing.username}" already exists (UUID: ${existing.uuid.substring(0, 8)}...). Use the identity list to manage it.`, 'error');
|
||||
return;
|
||||
}
|
||||
// Check if UUID already used by another identity
|
||||
const uuidMatch = mappings.find(m => m.uuid.toLowerCase() === uuid.toLowerCase());
|
||||
if (uuidMatch) {
|
||||
showNotification(`This UUID is already used by identity "${uuidMatch.username}". Each identity must have a unique UUID.`, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check username reservation on auth server
|
||||
try {
|
||||
const cfg = await window.electronAPI.loadConfig();
|
||||
const authDomain = cfg.authDomain || 'auth.sanasol.ws';
|
||||
const checkResp = await fetch(`https://${authDomain}/player/username/status/${encodeURIComponent(username)}`);
|
||||
if (checkResp.ok) {
|
||||
const status = await checkResp.json();
|
||||
if (status.reserved) {
|
||||
showNotification(`Username "${username}" is reserved by another player who set a password. Choose a different name.`, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Server check failed — allow creation (fail-open)
|
||||
console.log('[Identity] Server username check skipped:', e.message);
|
||||
}
|
||||
|
||||
// Check if UUID is password-protected on server (restore access flow)
|
||||
let uuidIsProtected = false;
|
||||
let registeredName = null;
|
||||
try {
|
||||
if (window.electronAPI.checkPasswordStatus) {
|
||||
const pwStatus = await window.electronAPI.checkPasswordStatus(uuid);
|
||||
if (pwStatus && pwStatus.hasPassword) {
|
||||
uuidIsProtected = true;
|
||||
registeredName = pwStatus.registeredName || null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[Identity] UUID password check skipped:', e.message);
|
||||
}
|
||||
|
||||
if (uuidIsProtected) {
|
||||
// UUID is password-protected — need password to restore it
|
||||
showRestoreProtectedIdentityDialog(username, uuid, registeredName);
|
||||
return;
|
||||
}
|
||||
|
||||
await saveNewIdentity(username, uuid);
|
||||
} catch (error) {
|
||||
console.error('Error adding identity:', error);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.identityAddFailed') : 'Failed to add identity';
|
||||
@@ -931,6 +976,174 @@ async function confirmAddIdentity() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNewIdentity(username, uuid) {
|
||||
if (window.electronAPI && window.electronAPI.setUuidForUser) {
|
||||
const result = await window.electronAPI.setUuidForUser(username, uuid);
|
||||
if (result.success) {
|
||||
showNotification('Identity added successfully!', 'success');
|
||||
hideAddIdentityForm();
|
||||
await loadAllUuids();
|
||||
if (window.loadIdentities) window.loadIdentities();
|
||||
updatePasswordShieldIcon();
|
||||
} else if (result.error === 'duplicate') {
|
||||
showNotification(`Identity "${username}" already exists (UUID: ${result.existingUuid.substring(0, 8)}...). Use the identity list to manage it.`, 'error');
|
||||
} else if (result.error === 'uuid_in_use') {
|
||||
showNotification(`This UUID is already used by identity "${result.existingUsername}". Each identity must have a unique UUID.`, 'error');
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to add identity');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showRestoreProtectedIdentityDialog(username, uuid, registeredName) {
|
||||
const existing = document.querySelector('.custom-confirm-modal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const nameWarning = registeredName && registeredName.toLowerCase() !== username.toLowerCase()
|
||||
? `<p style="color: #f59e0b; margin: 0 0 12px; font-size: 0.9rem;">
|
||||
<i class="fas fa-exclamation-triangle"></i> This UUID is locked to name "<strong>${escapeHtml(registeredName)}</strong>".
|
||||
Your entered name "${escapeHtml(username)}" will be replaced.
|
||||
</p>`
|
||||
: '';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'custom-confirm-modal';
|
||||
overlay.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.style.cssText = `
|
||||
background: #1f2937; border-radius: 12px; padding: 0;
|
||||
min-width: 420px; max-width: 520px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.6);
|
||||
border: 1px solid rgba(147, 51, 234, 0.4);
|
||||
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-shield-alt" style="font-size: 24px;"></i>
|
||||
<h3 style="margin: 0; font-size: 1.2rem; font-weight: 600;">Restore Protected Identity</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 24px;">
|
||||
<p style="color: #e5e7eb; margin: 0 0 16px; line-height: 1.6;">
|
||||
This UUID is <strong style="color: #22c55e;">password-protected</strong>. Enter the password to restore access.
|
||||
</p>
|
||||
${nameWarning}
|
||||
<div id="restoreError" style="display: none; color: #f87171; background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 8px; padding: 8px 12px; margin-bottom: 12px; font-size: 0.85rem;"></div>
|
||||
<input type="password" id="restorePasswordInput" style="
|
||||
width: 100%; box-sizing: border-box; padding: 10px 14px;
|
||||
background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2);
|
||||
border-radius: 8px; color: #fff; font-size: 0.95rem; outline: none;
|
||||
" placeholder="Password" autofocus />
|
||||
</div>
|
||||
<div style="padding: 16px 24px; display: flex; gap: 10px; justify-content: flex-end; border-top: 1px solid rgba(255,255,255,0.1);">
|
||||
<button id="restoreCancelBtn" style="
|
||||
padding: 8px 20px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2);
|
||||
background: transparent; color: #9ca3af; cursor: pointer; font-size: 0.9rem;
|
||||
">Cancel</button>
|
||||
<button id="restoreConfirmBtn" style="
|
||||
padding: 8px 20px; border-radius: 8px; border: none;
|
||||
background: linear-gradient(135deg, #9333ea, #3b82f6); color: white;
|
||||
cursor: pointer; font-weight: 600; font-size: 0.9rem;
|
||||
">Verify & Restore</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
requestAnimationFrame(() => {
|
||||
overlay.style.opacity = '1';
|
||||
dialog.style.transform = 'scale(1)';
|
||||
});
|
||||
|
||||
const input = overlay.querySelector('#restorePasswordInput');
|
||||
const errorMsg = overlay.querySelector('#restoreError');
|
||||
const confirmBtn = overlay.querySelector('#restoreConfirmBtn');
|
||||
const cancelBtn = overlay.querySelector('#restoreCancelBtn');
|
||||
let busy = false;
|
||||
|
||||
const close = () => { overlay.remove(); };
|
||||
cancelBtn.onclick = close;
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
const doRestore = async () => {
|
||||
if (busy) return;
|
||||
const password = input.value.trim();
|
||||
if (!password) {
|
||||
errorMsg.textContent = 'Password is required';
|
||||
errorMsg.style.display = 'block';
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Verifying...';
|
||||
errorMsg.style.display = 'none';
|
||||
|
||||
try {
|
||||
// Use the registered name if UUID is name-locked
|
||||
const finalName = registeredName || username;
|
||||
|
||||
// Verify password by attempting to get tokens
|
||||
const cfg = await window.electronAPI.loadConfig();
|
||||
const authDomain = cfg.authDomain || 'auth.sanasol.ws';
|
||||
const resp = await fetch(`https://${authDomain}/game-session/new`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uuid, name: finalName, password, scopes: 'hytale:server hytale:client' })
|
||||
});
|
||||
|
||||
if (resp.status === 401 || resp.status === 429) {
|
||||
const err = await resp.json();
|
||||
errorMsg.textContent = err.error || 'Incorrect password';
|
||||
errorMsg.style.display = 'block';
|
||||
input.value = '';
|
||||
input.focus();
|
||||
busy = false;
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Verify & Restore';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Server returned ${resp.status}`);
|
||||
}
|
||||
|
||||
// Password verified — save identity locally (force to allow the name)
|
||||
close();
|
||||
if (window.electronAPI && window.electronAPI.setUuidForUser) {
|
||||
const result = await window.electronAPI.setUuidForUser(finalName, uuid, true);
|
||||
if (result.success || (result && result.uuid)) {
|
||||
showNotification(`Identity "${finalName}" restored successfully!`, 'success');
|
||||
hideAddIdentityForm();
|
||||
await loadAllUuids();
|
||||
if (window.loadIdentities) window.loadIdentities();
|
||||
updatePasswordShieldIcon();
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to save identity', 'error');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg.textContent = 'Error: ' + e.message;
|
||||
errorMsg.style.display = 'block';
|
||||
busy = false;
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Verify & Restore';
|
||||
}
|
||||
};
|
||||
|
||||
confirmBtn.onclick = doRestore;
|
||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doRestore(); });
|
||||
}
|
||||
|
||||
function toggleAdvancedSection() {
|
||||
if (!uuidAdvancedContent || !uuidAdvancedToggle) return;
|
||||
const isOpen = uuidAdvancedContent.style.display !== 'none';
|
||||
@@ -941,6 +1154,250 @@ function toggleAdvancedSection() {
|
||||
}
|
||||
}
|
||||
|
||||
// Password section toggle
|
||||
function togglePasswordSection() {
|
||||
const content = document.getElementById('passwordSectionContent');
|
||||
const toggle = document.getElementById('passwordSectionToggle');
|
||||
if (!content || !toggle) return;
|
||||
const isOpen = content.style.display !== 'none';
|
||||
content.style.display = isOpen ? 'none' : 'block';
|
||||
const chevron = toggle.querySelector('.uuid-advanced-chevron');
|
||||
if (chevron) chevron.classList.toggle('open', !isOpen);
|
||||
if (!isOpen) refreshPasswordStatus();
|
||||
}
|
||||
|
||||
async function refreshPasswordStatus() {
|
||||
const statusMsg = document.getElementById('passwordStatusMsg');
|
||||
const currentPwInput = document.getElementById('currentPasswordInput');
|
||||
const removeBtn = document.getElementById('removePasswordBtn');
|
||||
const setBtn = document.getElementById('setPasswordBtn');
|
||||
try {
|
||||
const uuid = await window.electronAPI?.getCurrentUuid();
|
||||
if (!uuid) { if (statusMsg) statusMsg.textContent = 'No UUID available'; return; }
|
||||
const result = await window.electronAPI.checkPasswordStatus(uuid);
|
||||
if (result && result.hasPassword) {
|
||||
if (statusMsg) statusMsg.innerHTML = '<i class="fas fa-lock" style="color:#22c55e"></i> Password is set for this UUID';
|
||||
if (currentPwInput) currentPwInput.style.display = '';
|
||||
if (removeBtn) removeBtn.style.display = '';
|
||||
if (setBtn) { const span = setBtn.querySelector('span'); if (span) span.textContent = 'Change Password'; }
|
||||
} else {
|
||||
if (statusMsg) statusMsg.innerHTML = '<i class="fas fa-unlock" style="color:#f59e0b"></i> No password set — anyone can use this UUID';
|
||||
if (currentPwInput) currentPwInput.style.display = 'none';
|
||||
if (removeBtn) removeBtn.style.display = 'none';
|
||||
if (setBtn) { const span = setBtn.querySelector('span'); if (span) span.textContent = 'Set Password'; }
|
||||
}
|
||||
} catch (e) {
|
||||
if (statusMsg) statusMsg.textContent = 'Could not check password status';
|
||||
}
|
||||
}
|
||||
|
||||
window.handleSetPassword = async function () {
|
||||
const newPw = document.getElementById('newPasswordInput');
|
||||
const currentPw = document.getElementById('currentPasswordInput');
|
||||
const setBtn = document.getElementById('setPasswordBtn');
|
||||
if (!newPw || !newPw.value || newPw.value.length < 6) {
|
||||
showNotification('Password must be at least 6 characters', 'error');
|
||||
return;
|
||||
}
|
||||
if (setBtn) { setBtn.disabled = true; setBtn.textContent = 'Setting...'; }
|
||||
try {
|
||||
const uuid = await window.electronAPI.getCurrentUuid();
|
||||
const result = await window.electronAPI.setPlayerPassword(uuid, newPw.value, currentPw?.value || null);
|
||||
if (result.success) {
|
||||
showNotification('Password set successfully', 'success');
|
||||
newPw.value = '';
|
||||
if (currentPw) currentPw.value = '';
|
||||
refreshPasswordStatus();
|
||||
updatePasswordShieldIcon();
|
||||
if (window.loadIdentities) window.loadIdentities();
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to set password', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Error: ' + e.message, 'error');
|
||||
} finally {
|
||||
if (setBtn) { setBtn.disabled = false; setBtn.textContent = 'Set Password'; }
|
||||
}
|
||||
};
|
||||
|
||||
window.handleRemovePassword = async function () {
|
||||
const currentPw = document.getElementById('currentPasswordInput');
|
||||
const removeBtn = document.getElementById('removePasswordBtn');
|
||||
if (!currentPw || !currentPw.value) {
|
||||
showNotification('Enter your current password to remove it', 'error');
|
||||
return;
|
||||
}
|
||||
if (removeBtn) { removeBtn.disabled = true; removeBtn.textContent = 'Removing...'; }
|
||||
try {
|
||||
const uuid = await window.electronAPI.getCurrentUuid();
|
||||
const result = await window.electronAPI.removePlayerPassword(uuid, currentPw.value);
|
||||
if (result.success) {
|
||||
showNotification('Password removed', 'success');
|
||||
currentPw.value = '';
|
||||
refreshPasswordStatus();
|
||||
updatePasswordShieldIcon();
|
||||
if (window.loadIdentities) window.loadIdentities();
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to remove password', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Error: ' + e.message, 'error');
|
||||
} finally {
|
||||
if (removeBtn) { removeBtn.disabled = false; removeBtn.textContent = 'Remove Password'; }
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Password Shield Icon ───
|
||||
|
||||
window.updatePasswordShieldIcon = updatePasswordShieldIcon;
|
||||
async function updatePasswordShieldIcon() {
|
||||
const icon = document.getElementById('passwordShieldIcon');
|
||||
if (!icon) return;
|
||||
try {
|
||||
const uuid = await window.electronAPI?.getCurrentUuid();
|
||||
if (!uuid) {
|
||||
icon.className = 'fas fa-unlock password-shield unprotected';
|
||||
icon.setAttribute('data-tooltip', 'No identity loaded');
|
||||
return;
|
||||
}
|
||||
const result = await window.electronAPI.checkPasswordStatus(uuid);
|
||||
if (result && result.hasPassword) {
|
||||
icon.className = 'fas fa-lock password-shield protected';
|
||||
icon.setAttribute('data-tooltip', 'Protected — click to manage');
|
||||
} else {
|
||||
icon.className = 'fas fa-unlock password-shield unprotected';
|
||||
icon.setAttribute('data-tooltip', 'Click to protect identity');
|
||||
}
|
||||
} catch (e) {
|
||||
icon.className = 'fas fa-unlock password-shield unprotected';
|
||||
icon.setAttribute('data-tooltip', 'Click to protect identity');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Password Modal ───
|
||||
|
||||
window.openPasswordModal = async function () {
|
||||
const modal = document.getElementById('passwordModal');
|
||||
if (!modal) return;
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Clear inputs
|
||||
const newPw = document.getElementById('pwModalNewPassword');
|
||||
const curPw = document.getElementById('pwModalCurrentPassword');
|
||||
if (newPw) newPw.value = '';
|
||||
if (curPw) curPw.value = '';
|
||||
|
||||
// Load current identity info
|
||||
try {
|
||||
const username = await window.electronAPI?.loadUsername() || 'Player';
|
||||
const uuid = await window.electronAPI?.getCurrentUuid() || '';
|
||||
document.getElementById('pwModalName').textContent = username;
|
||||
document.getElementById('pwModalUuid').textContent = uuid || 'No UUID';
|
||||
|
||||
// Check password status
|
||||
const result = uuid ? await window.electronAPI.checkPasswordStatus(uuid) : null;
|
||||
const badge = document.getElementById('pwModalStatusBadge');
|
||||
const statusText = document.getElementById('pwModalStatusText');
|
||||
const curPwInput = document.getElementById('pwModalCurrentPassword');
|
||||
const removeBtn = document.getElementById('pwModalRemoveBtn');
|
||||
const setBtn = document.getElementById('pwModalSetBtn');
|
||||
const usernameInfo = document.getElementById('pwModalUsernameInfo');
|
||||
|
||||
if (result && result.hasPassword) {
|
||||
badge.innerHTML = '<span style="color:#22c55e;font-size:0.8em;padding:3px 8px;background:rgba(34,197,94,0.15);border-radius:6px;"><i class="fas fa-lock"></i> Protected</span>';
|
||||
statusText.innerHTML = '<i class="fas fa-check-circle" style="color:#22c55e"></i> Password set — your UUID and username are protected';
|
||||
if (curPwInput) curPwInput.style.display = '';
|
||||
if (removeBtn) removeBtn.style.display = '';
|
||||
if (setBtn) { const s = setBtn.querySelector('span'); if (s) s.textContent = 'Change Password'; }
|
||||
if (usernameInfo) usernameInfo.style.display = 'none';
|
||||
} else {
|
||||
badge.innerHTML = '<span style="color:#f59e0b;font-size:0.8em;padding:3px 8px;background:rgba(245,158,11,0.1);border-radius:6px;"><i class="fas fa-unlock"></i> Open</span>';
|
||||
statusText.innerHTML = '<i class="fas fa-exclamation-triangle" style="color:#f59e0b"></i> Anyone can use this UUID and username';
|
||||
if (curPwInput) curPwInput.style.display = 'none';
|
||||
if (removeBtn) removeBtn.style.display = 'none';
|
||||
if (setBtn) { const s = setBtn.querySelector('span'); if (s) s.textContent = 'Set Password'; }
|
||||
if (usernameInfo) usernameInfo.style.display = '';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('pwModalStatusText').textContent = 'Could not check password status';
|
||||
}
|
||||
};
|
||||
|
||||
window.closePasswordModal = function () {
|
||||
const modal = document.getElementById('passwordModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
};
|
||||
|
||||
window.handlePasswordModalSet = async function () {
|
||||
const newPw = document.getElementById('pwModalNewPassword');
|
||||
const curPw = document.getElementById('pwModalCurrentPassword');
|
||||
const setBtn = document.getElementById('pwModalSetBtn');
|
||||
if (!newPw || !newPw.value || newPw.value.length < 6) {
|
||||
showNotification('Password must be at least 6 characters', 'error');
|
||||
return;
|
||||
}
|
||||
if (setBtn) { setBtn.disabled = true; const s = setBtn.querySelector('span'); if (s) s.textContent = 'Saving...'; }
|
||||
try {
|
||||
const uuid = await window.electronAPI.getCurrentUuid();
|
||||
const result = await window.electronAPI.setPlayerPassword(uuid, newPw.value, curPw?.value || null);
|
||||
if (result.success) {
|
||||
const msg = result.username_reserved ? 'Password set! Username "' + (result.reserved_username || '') + '" reserved.' : 'Password set!';
|
||||
showNotification(msg, 'success');
|
||||
newPw.value = '';
|
||||
if (curPw) curPw.value = '';
|
||||
updatePasswordShieldIcon();
|
||||
if (window.loadIdentities) window.loadIdentities();
|
||||
openPasswordModal(); // refresh modal state
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to set password', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Error: ' + e.message, 'error');
|
||||
} finally {
|
||||
if (setBtn) { setBtn.disabled = false; const s = setBtn.querySelector('span'); if (s) s.textContent = 'Set Password'; }
|
||||
}
|
||||
};
|
||||
|
||||
window.handlePasswordModalRemove = async function () {
|
||||
const curPw = document.getElementById('pwModalCurrentPassword');
|
||||
const removeBtn = document.getElementById('pwModalRemoveBtn');
|
||||
if (!curPw || !curPw.value) {
|
||||
showNotification('Enter your current password to remove it', 'error');
|
||||
return;
|
||||
}
|
||||
if (removeBtn) { removeBtn.disabled = true; removeBtn.textContent = 'Removing...'; }
|
||||
try {
|
||||
const uuid = await window.electronAPI.getCurrentUuid();
|
||||
const result = await window.electronAPI.removePlayerPassword(uuid, curPw.value);
|
||||
if (result.success) {
|
||||
showNotification('Password removed', 'success');
|
||||
curPw.value = '';
|
||||
updatePasswordShieldIcon();
|
||||
if (window.loadIdentities) window.loadIdentities();
|
||||
openPasswordModal(); // refresh modal state
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to remove password', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Error: ' + e.message, 'error');
|
||||
} finally {
|
||||
if (removeBtn) { removeBtn.disabled = false; removeBtn.textContent = 'Remove Password'; }
|
||||
}
|
||||
};
|
||||
|
||||
// Close modal on backdrop click
|
||||
document.addEventListener('click', (e) => {
|
||||
const modal = document.getElementById('passwordModal');
|
||||
if (modal && e.target === modal) closePasswordModal();
|
||||
});
|
||||
|
||||
// Bind password section toggle (for legacy UUID modal section)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const pwToggle = document.getElementById('passwordSectionToggle');
|
||||
if (pwToggle) pwToggle.addEventListener('click', togglePasswordSection);
|
||||
updatePasswordShieldIcon();
|
||||
});
|
||||
|
||||
window.regenerateUuidForUser = async function (username) {
|
||||
try {
|
||||
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.';
|
||||
@@ -1016,7 +1473,7 @@ async function performSetCustomUuid(uuid) {
|
||||
showNotification(msg, 'error');
|
||||
return;
|
||||
}
|
||||
const result = await window.electronAPI.setUuidForUser(username, uuid);
|
||||
const result = await window.electronAPI.setUuidForUser(username, uuid, true); // force: true — explicit UUID change
|
||||
|
||||
if (result.success) {
|
||||
if (currentUuidDisplay) currentUuidDisplay.value = uuid;
|
||||
@@ -1027,6 +1484,7 @@ async function performSetCustomUuid(uuid) {
|
||||
|
||||
await loadAllUuids();
|
||||
if (window.loadIdentities) window.loadIdentities();
|
||||
updatePasswordShieldIcon();
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to set custom UUID');
|
||||
}
|
||||
@@ -1105,8 +1563,9 @@ async function performSwitchToUsername(username) {
|
||||
// Refresh the UUID list to show new "Current" badge
|
||||
await loadAllUuids();
|
||||
|
||||
// Refresh header identity dropdown
|
||||
// Refresh header identity dropdown + shield icon
|
||||
if (window.loadIdentities) window.loadIdentities();
|
||||
updatePasswordShieldIcon();
|
||||
|
||||
const msg = window.i18n
|
||||
? window.i18n.t('notifications.switchUsernameSuccess').replace('{username}', username)
|
||||
@@ -1124,46 +1583,195 @@ async function performSwitchToUsername(username) {
|
||||
|
||||
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';
|
||||
// Look up UUID for this username
|
||||
let uuid = null;
|
||||
if (window.electronAPI && window.electronAPI.getAllUuidMappings) {
|
||||
const mappings = await window.electronAPI.getAllUuidMappings();
|
||||
const entry = mappings.find(m => m.username.toLowerCase() === username.toLowerCase());
|
||||
if (entry) uuid = entry.uuid;
|
||||
}
|
||||
|
||||
showCustomConfirm(
|
||||
message,
|
||||
title,
|
||||
async () => {
|
||||
await performDeleteUuid(username);
|
||||
},
|
||||
null,
|
||||
confirmBtn,
|
||||
cancelBtn
|
||||
);
|
||||
// Check if password-protected
|
||||
let isProtected = false;
|
||||
if (uuid && window.electronAPI && window.electronAPI.checkPasswordStatus) {
|
||||
try {
|
||||
const pwStatus = await window.electronAPI.checkPasswordStatus(uuid);
|
||||
isProtected = pwStatus && pwStatus.hasPassword;
|
||||
} catch (e) {
|
||||
console.log('[Identity] Password status check failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (isProtected) {
|
||||
// Password-protected identity — show warning with password input
|
||||
showPasswordProtectedDeleteDialog(username, uuid);
|
||||
} else {
|
||||
// Normal identity — simple confirm
|
||||
const message = `Are you sure you want to delete the identity "${username}"? This action cannot be undone.`;
|
||||
showCustomConfirm(
|
||||
message,
|
||||
'Delete Identity',
|
||||
async () => { await performDeleteUuid(username); },
|
||||
null,
|
||||
'Delete',
|
||||
'Cancel'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in deleteUuid:', error);
|
||||
const msg = window.i18n ? window.i18n.t('notifications.uuidDeleteFailed') : 'Failed to delete UUID';
|
||||
showNotification(msg, 'error');
|
||||
showNotification('Failed to delete identity', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
function showPasswordProtectedDeleteDialog(username, uuid) {
|
||||
const existing = document.querySelector('.custom-confirm-modal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'custom-confirm-modal';
|
||||
overlay.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.style.cssText = `
|
||||
background: #1f2937; border-radius: 12px; padding: 0;
|
||||
min-width: 420px; max-width: 520px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.6);
|
||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||
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-shield-alt" style="font-size: 24px;"></i>
|
||||
<h3 style="margin: 0; font-size: 1.2rem; font-weight: 600;">Delete Protected Identity</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 24px;">
|
||||
<p style="color: #e5e7eb; margin: 0 0 16px; line-height: 1.6;">
|
||||
<strong>"${escapeHtml(username)}"</strong> is password-protected. Deleting it will:
|
||||
</p>
|
||||
<ul style="color: #f87171; margin: 0 0 16px; padding-left: 20px; line-height: 1.8;">
|
||||
<li>Remove the password protection from this UUID</li>
|
||||
<li>Release the reserved username "${escapeHtml(username)}"</li>
|
||||
<li>Allow anyone to use this UUID and name</li>
|
||||
</ul>
|
||||
<p style="color: #9ca3af; margin: 0 0 16px; font-size: 0.9rem;">
|
||||
Enter your current password to confirm deletion:
|
||||
</p>
|
||||
<div id="pwDeleteError" style="display: none; color: #f87171; background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 8px; padding: 8px 12px; margin-bottom: 12px; font-size: 0.85rem;"></div>
|
||||
<input type="password" id="pwDeleteInput" style="
|
||||
width: 100%; box-sizing: border-box; padding: 10px 14px;
|
||||
background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2);
|
||||
border-radius: 8px; color: #fff; font-size: 0.95rem; outline: none;
|
||||
" placeholder="Current password" autofocus />
|
||||
</div>
|
||||
<div style="padding: 16px 24px; display: flex; gap: 10px; justify-content: flex-end; border-top: 1px solid rgba(255,255,255,0.1);">
|
||||
<button id="pwDeleteCancelBtn" style="
|
||||
padding: 8px 20px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2);
|
||||
background: transparent; color: #9ca3af; cursor: pointer; font-size: 0.9rem;
|
||||
">Cancel</button>
|
||||
<button id="pwDeleteConfirmBtn" style="
|
||||
padding: 8px 20px; border-radius: 8px; border: none;
|
||||
background: #ef4444; color: white; cursor: pointer; font-weight: 600; font-size: 0.9rem;
|
||||
">Delete & Remove Password</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
requestAnimationFrame(() => {
|
||||
overlay.style.opacity = '1';
|
||||
dialog.style.transform = 'scale(1)';
|
||||
});
|
||||
|
||||
const input = overlay.querySelector('#pwDeleteInput');
|
||||
const errorMsg = overlay.querySelector('#pwDeleteError');
|
||||
const confirmBtn = overlay.querySelector('#pwDeleteConfirmBtn');
|
||||
const cancelBtn = overlay.querySelector('#pwDeleteCancelBtn');
|
||||
let busy = false;
|
||||
|
||||
const close = () => { overlay.remove(); };
|
||||
|
||||
cancelBtn.onclick = close;
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
const doDelete = async () => {
|
||||
if (busy) return;
|
||||
const password = input.value.trim();
|
||||
if (!password) {
|
||||
errorMsg.textContent = 'Password is required';
|
||||
errorMsg.style.display = 'block';
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Removing...';
|
||||
errorMsg.style.display = 'none';
|
||||
|
||||
try {
|
||||
// Step 1: Remove password on server (validates current password)
|
||||
const removeResult = await window.electronAPI.removePlayerPassword(uuid, password);
|
||||
if (!removeResult.success) {
|
||||
errorMsg.textContent = removeResult.error || 'Incorrect password';
|
||||
errorMsg.style.display = 'block';
|
||||
input.value = '';
|
||||
input.focus();
|
||||
busy = false;
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Delete & Remove Password';
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Also clear saved password if any
|
||||
try {
|
||||
const cfg = await window.electronAPI.loadConfig();
|
||||
if (cfg.savedPasswords && cfg.savedPasswords[uuid]) {
|
||||
delete cfg.savedPasswords[uuid];
|
||||
await window.electronAPI.saveConfig({ savedPasswords: cfg.savedPasswords });
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// Step 3: Delete identity locally
|
||||
close();
|
||||
await performDeleteUuid(username);
|
||||
} catch (e) {
|
||||
errorMsg.textContent = 'Error: ' + e.message;
|
||||
errorMsg.style.display = 'block';
|
||||
busy = false;
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Delete & Remove Password';
|
||||
}
|
||||
};
|
||||
|
||||
confirmBtn.onclick = doDelete;
|
||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doDelete(); });
|
||||
}
|
||||
|
||||
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');
|
||||
showNotification('Identity deleted successfully!', 'success');
|
||||
await loadAllUuids();
|
||||
if (window.loadIdentities) window.loadIdentities();
|
||||
updatePasswordShieldIcon();
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to delete UUID');
|
||||
throw new Error(result.error || 'Failed to delete identity');
|
||||
}
|
||||
}
|
||||
} 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');
|
||||
showNotification(`Failed to delete identity: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1316,6 +1924,11 @@ async function switchBranch(newBranch) {
|
||||
await loadVersionBranch();
|
||||
console.log('[Settings] Radio buttons updated after branch switch');
|
||||
|
||||
// Refresh game info bar on play page
|
||||
if (window.LauncherUI?.loadGameInfoBar) {
|
||||
window.LauncherUI.loadGameInfoBar();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.hideProgress();
|
||||
|
||||
59
GUI/js/ui.js
59
GUI/js/ui.js
@@ -138,6 +138,7 @@ function showLauncherOrInstall(isInstalled) {
|
||||
if (gameTitle) gameTitle.style.display = '';
|
||||
showPage('play-page');
|
||||
setActiveNav('play');
|
||||
loadGameInfoBar();
|
||||
} else {
|
||||
if (launcher) launcher.style.display = 'none';
|
||||
if (install) {
|
||||
@@ -738,13 +739,69 @@ async function checkGameInstallation() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGameInfoBar() {
|
||||
try {
|
||||
const info = await window.electronAPI.getGameInfo();
|
||||
const bar = document.getElementById('game-info-bar');
|
||||
if (!bar) return;
|
||||
|
||||
const versionEl = document.getElementById('game-version-info');
|
||||
const branchEl = document.getElementById('game-branch-info');
|
||||
const lastPlayedEl = document.getElementById('game-last-played');
|
||||
|
||||
if (versionEl) {
|
||||
versionEl.classList.remove('game-info-loading');
|
||||
if (info.version) {
|
||||
const display = info.readableVersion
|
||||
? `${info.version} - ${info.readableVersion}`
|
||||
: info.version;
|
||||
versionEl.textContent = display;
|
||||
} else {
|
||||
versionEl.textContent = 'Not installed';
|
||||
}
|
||||
}
|
||||
if (branchEl) {
|
||||
branchEl.classList.remove('game-info-loading');
|
||||
const isPreRelease = info.branch === 'pre-release';
|
||||
branchEl.textContent = isPreRelease ? 'Pre-Release' : 'Release';
|
||||
branchEl.style.color = isPreRelease ? '#f59e0b' : '';
|
||||
}
|
||||
|
||||
if (lastPlayedEl && info.lastPlayed) {
|
||||
lastPlayedEl.style.display = '';
|
||||
if (!lastPlayedEl.previousElementSibling?.classList?.contains('game-info-sep')) {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'game-info-sep';
|
||||
lastPlayedEl.before(sep);
|
||||
}
|
||||
lastPlayedEl.textContent = formatTimeAgo(info.lastPlayed);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not load game info:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp) {
|
||||
const diff = Date.now() - timestamp;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return 'Just played';
|
||||
if (minutes < 60) return `Played ${minutes}m ago`;
|
||||
if (hours < 24) return `Played ${hours}h ago`;
|
||||
if (days < 30) return `Played ${days}d ago`;
|
||||
return `Played ${Math.floor(days / 30)}mo ago`;
|
||||
}
|
||||
|
||||
window.LauncherUI = {
|
||||
showPage,
|
||||
setActiveNav,
|
||||
showLauncherOrInstall,
|
||||
showProgress,
|
||||
hideProgress,
|
||||
updateProgress
|
||||
updateProgress,
|
||||
loadGameInfoBar
|
||||
};
|
||||
|
||||
// Make installation effects globally available
|
||||
|
||||
@@ -16,6 +16,8 @@ class ClientUpdateManager {
|
||||
});
|
||||
|
||||
window.electronAPI.onUpdateDownloadProgress((progress) => {
|
||||
console.log('📊 download-progress event:', progress);
|
||||
this.downloadProgressReceived = true;
|
||||
this.updateDownloadProgress(progress);
|
||||
});
|
||||
|
||||
@@ -49,7 +51,7 @@ class ClientUpdateManager {
|
||||
|
||||
const popupHTML = `
|
||||
<div id="update-popup-overlay">
|
||||
<div class="update-popup-container update-popup-pulse">
|
||||
<div class="update-popup-container">
|
||||
<div class="update-popup-header">
|
||||
<div class="update-popup-icon">
|
||||
<i class="fas fa-download"></i>
|
||||
@@ -118,11 +120,29 @@ class ClientUpdateManager {
|
||||
this.blockInterface();
|
||||
|
||||
// Show progress container immediately (auto-download is enabled)
|
||||
this.downloadProgressReceived = false;
|
||||
const progressContainer = document.getElementById('update-progress-container');
|
||||
if (progressContainer) {
|
||||
progressContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
// If no progress events arrive within 2 seconds, show indeterminate animation
|
||||
setTimeout(() => {
|
||||
if (!this.downloadProgressReceived && progressContainer) {
|
||||
const bar = document.getElementById('update-progress-bar');
|
||||
const speed = document.getElementById('update-progress-speed');
|
||||
const percent = document.getElementById('update-progress-percent');
|
||||
if (bar) {
|
||||
bar.style.width = '100%';
|
||||
bar.style.animation = 'indeterminateProgress 1.5s ease-in-out infinite';
|
||||
bar.style.opacity = '0.7';
|
||||
}
|
||||
if (speed) speed.textContent = '';
|
||||
if (percent) percent.textContent = 'Downloading...';
|
||||
console.log('⏳ No download-progress events received, showing indeterminate progress');
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
const installBtn = document.getElementById('update-install-btn');
|
||||
if (installBtn) {
|
||||
installBtn.addEventListener('click', async (e) => {
|
||||
@@ -248,6 +268,10 @@ class ClientUpdateManager {
|
||||
const progressSize = document.getElementById('update-progress-size');
|
||||
|
||||
if (progressBar && progress) {
|
||||
// Stop indeterminate animation if it was running
|
||||
progressBar.style.animation = 'none';
|
||||
progressBar.style.opacity = '1';
|
||||
|
||||
const percent = Math.round(progress.percent || 0);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
|
||||
@@ -273,57 +297,35 @@ class ClientUpdateManager {
|
||||
showUpdateDownloaded(updateInfo) {
|
||||
const statusText = document.getElementById('update-status-text');
|
||||
const progressContainer = document.getElementById('update-progress-container');
|
||||
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');
|
||||
const buttonsContainer = document.getElementById('update-buttons-container');
|
||||
const installBtn = document.getElementById('update-install-btn');
|
||||
const downloadBtn = document.getElementById('update-download-btn');
|
||||
const skipBtn = document.getElementById('update-skip-btn');
|
||||
const footerText = document.getElementById('update-footer-text');
|
||||
const popupContainer = document.querySelector('.update-popup-container');
|
||||
|
||||
// Remove breathing/pulse animation when download is complete
|
||||
if (popupContainer) {
|
||||
popupContainer.classList.remove('update-popup-pulse');
|
||||
// Stop indeterminate animation and show 100%
|
||||
if (progressBar) {
|
||||
progressBar.style.animation = 'none';
|
||||
progressBar.style.opacity = '1';
|
||||
progressBar.style.width = '100%';
|
||||
}
|
||||
if (progressPercent) progressPercent.textContent = '100%';
|
||||
if (progressSpeed) progressSpeed.textContent = 'Complete';
|
||||
if (progressContainer) progressContainer.style.display = 'block';
|
||||
|
||||
// Hide progress after a short delay so user sees 100%
|
||||
setTimeout(() => {
|
||||
if (progressContainer) progressContainer.style.display = 'none';
|
||||
}, 1500);
|
||||
|
||||
if (statusText) {
|
||||
statusText.textContent = 'Update downloaded! Ready to install.';
|
||||
}
|
||||
|
||||
if (progressContainer) {
|
||||
progressContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Use platform info from main process if available, fallback to browser detection
|
||||
const autoInstallSupported = updateInfo.autoInstallSupported !== undefined
|
||||
? updateInfo.autoInstallSupported
|
||||
: navigator.platform.toUpperCase().indexOf('MAC') < 0;
|
||||
|
||||
if (!autoInstallSupported) {
|
||||
// macOS: Show manual download as primary since auto-update doesn't work
|
||||
if (statusText) {
|
||||
statusText.textContent = 'Update downloaded but auto-install may not work on macOS.';
|
||||
}
|
||||
|
||||
if (installBtn) {
|
||||
// Still show install button but as secondary option
|
||||
installBtn.classList.add('update-download-btn-secondary');
|
||||
installBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Try Install & Restart';
|
||||
}
|
||||
|
||||
if (downloadBtn) {
|
||||
// Make manual download primary
|
||||
downloadBtn.classList.remove('update-download-btn-secondary');
|
||||
downloadBtn.innerHTML = '<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>Download Manually (Recommended)';
|
||||
}
|
||||
|
||||
if (footerText) {
|
||||
footerText.textContent = 'Auto-install often fails on macOS:';
|
||||
}
|
||||
} else {
|
||||
// Windows/Linux: Auto-install should work
|
||||
if (statusText) {
|
||||
statusText.textContent = 'Update downloaded! Ready to install.';
|
||||
}
|
||||
|
||||
if (footerText) {
|
||||
footerText.textContent = 'Click to install the update:';
|
||||
}
|
||||
if (footerText) {
|
||||
footerText.textContent = 'Click to install the update:';
|
||||
}
|
||||
|
||||
if (buttonsContainer) {
|
||||
@@ -338,7 +340,7 @@ class ClientUpdateManager {
|
||||
console.error('❌ Skip button not found in DOM!');
|
||||
}
|
||||
|
||||
console.log('✅ Update downloaded, ready to install. autoInstallSupported:', autoInstallSupported);
|
||||
console.log('✅ Update downloaded, ready to install');
|
||||
}
|
||||
|
||||
handleUpdateError(errorInfo) {
|
||||
@@ -366,9 +368,6 @@ class ClientUpdateManager {
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
1687
GUI/style.css
1687
GUI/style.css
File diff suppressed because it is too large
Load Diff
505
README.md
505
README.md
@@ -1,497 +1,20 @@
|
||||
<div align="center">
|
||||
# Hytale F2P Launcher (Deprecated)
|
||||
|
||||
<header>
|
||||
<h1>🎮 Hytale F2P Launcher 🚀</h1>
|
||||
<h2>💻 Cross-Platform Multiplayer 🖥️</h2>
|
||||
<h3>Available for Windows 🪟, macOS 🍎, and Linux 🐧</h3>
|
||||
<p><small>An unofficial cross-platform launcher for Hytale with automatic updates and multiplayer support!</small></p>
|
||||
</header>
|
||||
> **This project has been superseded by [F2P Evo](https://git.sanhost.net/sanasol/f2p-evo).**
|
||||
> Download the new launcher: https://git.sanhost.net/sanasol/f2p-evo/releases/latest
|
||||
|
||||
[](https://github.com/amiayweb/Hytale-F2P/releases)
|
||||
[](https://github.com/amiayweb/Hytale-F2P/releases)
|
||||
[](https://github.com/amiayweb/Hytale-F2P/releases)
|
||||
F2P Evo is a complete rewrite with Tauri 2 + Vue 3 + Rust, featuring:
|
||||
- Multi-instance and multi-identity management
|
||||
- Built-in mod manager with CurseForge integration
|
||||
- Matcha social chat with friends and game invites
|
||||
- Differential updates, password-protected accounts, 12 languages
|
||||
|
||||
[](https://github.com/amiayweb/Hytale-F2P/stargazers)
|
||||
[](https://github.com/amiayweb/Hytale-F2P/network/members)
|
||||
[](https://github.com/amiayweb/Hytale-F2P/issues)
|
||||

|
||||
## Community
|
||||
|
||||
### ⚠️ **WARNING: READ [QUICK START](#-quick-start) before Downloading & Installing the Launcher!** ⚠️
|
||||
|
||||
#### 🛑 **Found a problem? [TG Group](https://t.me/sanhostnet) | [TG Channel](https://t.me/hf2p_og) | [Community Chat](https://chat.sanhost.net/invite/Tfz4jCK4)** 🛑
|
||||
<!-- #### 🛑 **Found a problem? [Join the HF2P Discord](https://discord.gg/Fhbb9Yk5WW) and head to `#-⚠️-community-help`** 🛑 -->
|
||||
|
||||
<p>
|
||||
👍 If you like the project, <b>feel free to support us via Buy Me a Coffee!</b> ☕<br>
|
||||
Any support is appreciated and helps keep the project going.
|
||||
</p>
|
||||
|
||||
<a href="https://buymeacoffee.com/hf2p">
|
||||
<img src="https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExem14OW1tanN3eHlyYmR4NW1sYmJkOTZmbmJxejdjZXB6MXY5cW12MSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9cw/TDQOtnWgsBx99cNoyH/giphy.gif" width="120">
|
||||
</a>
|
||||
|
||||
|
||||
⭐ **If you find this project useful, please give it a STAR!** ⭐
|
||||
|
||||
[](https://www.star-history.com/#amiayweb/Hytale-F2P&type=date&legend=top-left)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="https://i.imgur.com/wwuuMUf.png" alt="Hytale F2P Launcher" width="1000">
|
||||
<details>
|
||||
<summary><b>View Gallery</b></summary>
|
||||
<table style="width: 100%; border-spacing: 15px; border-collapse: separate;">
|
||||
<tr>
|
||||
<td align="center" style="vertical-align: top; width: 50%;">
|
||||
<b>Featured Servers 🆕</b><br>
|
||||
<img src="https://i.imgur.com/fEu9y3Z.png" alt="Hytale F2P Featured Servers" width="100%">
|
||||
</td>
|
||||
<td align="center" style="vertical-align: top; width: 50%;">
|
||||
<b>Settings Page ⚙️</b><br>
|
||||
<img src="https://i.imgur.com/l5iBzxc.png" alt="Hytale F2P Settings Page" width="100%">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="vertical-align: top; width: 50%;">
|
||||
<b>Downloadable Mods from CurseForge 🛠️</b><br>
|
||||
<img src="https://i.imgur.com/QIDbqYn.png" alt="Hytale F2P Mods Download" width="100%">
|
||||
</td>
|
||||
<td align="center" style="vertical-align: top; width: 50%;">
|
||||
<b>My Mods Menu 🔧</b><br>
|
||||
<img src="https://i.imgur.com/rjvwUfq.png" alt="Hytale F2P My Mods Menu" width="100%">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="vertical-align: top; width: 50%;">
|
||||
<b>In-Game Screenshot - Spawn Point 🎮</b><br>
|
||||
<img src="https://i.imgur.com/X8lNFQ7.png" alt="Hytale F2P In-Game Screenshot-1" width="100%">
|
||||
</td>
|
||||
<td align="center" style="vertical-align: top; width: 50%;">
|
||||
<b>In-Game Screenshot - Gameplay Terrain 🌳</b><br>
|
||||
<img src="https://i.imgur.com/3iRScPa.png" alt="Hytale F2P In-Game Screenshot-2" width="100%">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
---
|
||||
## ✨ Features
|
||||
|
||||
🎯 **Core Features**
|
||||
- 🔄 **Automatic Updates** - Smart version checking and seamless game updates
|
||||
- 💾 **Data Preservation** - Intelligent UserData backup and restoration during updates
|
||||
- 🌐 **Cross-Platform** - Full support for Windows x64, Linux x64 (X11/Wayland, SteamDeck), and macOS Silicon
|
||||
- ☕ **Java Management** - Automatic Java runtime detection and installation
|
||||
- 🎮 **Multiplayer Support** - Automatic multiplayer client installation (Windows, macOS & Linux !)
|
||||
|
||||
🛡️ **Advanced Features**
|
||||
- 📁 **Custom Installation** - Choose your own installation directory
|
||||
- 🔍 **Smart Detection** - Automatic game and dependency detection
|
||||
- 🗂️ **Mod Support** - Built-in mod management system
|
||||
- 📰 **News Feed** - Stay updated with the latest Hytale news
|
||||
- 🎨 **Modern UI** - Clean, responsive interface with dark theme
|
||||
|
||||
---
|
||||
|
||||
# 🚀 Quick Start
|
||||
|
||||
## 🖥️ System Requirements
|
||||
|
||||
### 🎮 Hytale Hardware Requirements
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Hytale is designed to be accessible while scaling for high-end performance.
|
||||
> Below are the [official system requirements for the Early Access](https://hytale.com/news/2025/12/hytale-hardware-requirements) release.
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>🥉 Minimum (1080p @ 30 FPS)</th>
|
||||
<th>🥈 Recommended (1080p @ 60 FPS)</th>
|
||||
<th>🥇 Best (1440p @ 60 FPS)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><b>🖥️ OS</b></td>
|
||||
<td colspan="3" align="center">
|
||||
Windows 10/11 (64-bit X64) | Linux (x64) | macOS (ARM64/Apple Silicon)
|
||||
<br />
|
||||
<small><i>⚠️ Note: ARM64 (Windows & Linux), macOS (x86/Intel) <b>are not supported!</b> ⚠️</i></small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>⚙️ CPU</b></td>
|
||||
<td>Intel i5-7500 / Ryzen 3 1200 / Apple M1</td>
|
||||
<td>Intel i5-10400 / Ryzen 5 3600 / Apple M2</td>
|
||||
<td>Intel i7-10700K / Ryzen 9 3800X / Apple M3</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>🧠 RAM</b></td>
|
||||
<td>8GB (dGPU) / 12GB (iGPU)<sup><a href="#fn1" id="ref1">1</a></sup></td>
|
||||
<td>16 GB</td>
|
||||
<td>32 GB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>🎮 GPU</b></td>
|
||||
<td>GTX 900 / RX 400 / UHD 620</td>
|
||||
<td>GTX 1060 / RX 580 / Iris Xe</td>
|
||||
<td>RTX 30 Series / RX 7000 Series</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>💾 Storage</b></td>
|
||||
<td>20 GB (SATA SSD)</td>
|
||||
<td>20 GB (NVMe SSD)</td>
|
||||
<td>50 GB+ (NVMe SSD)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>🌐 Network</b></td>
|
||||
<td>2 Mbit/s</td>
|
||||
<td>8 Mbit/s</td>
|
||||
<td>10+ Mbit/s</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="fn1"><sup>Note 1</sup> Using Discrete/Dedicated GPU (dGPU) must have 8 GB RAM minimum, while using Integrated GPU (iGPU) must have 12 GB RAM.</p>
|
||||
|
||||
> [!WARNING]
|
||||
> Our launcher has **not yet** supported Offline Mode (playing Hytale without internet).
|
||||
> We will surely add the feature as soon as possible. Kindly wait for the update.
|
||||
|
||||
---
|
||||
|
||||
### 🪟 Windows Prequisites
|
||||
* **Java JDK 25:**
|
||||
* [Oracle](https://www.oracle.com/java/technologies/downloads/#jdk25-windows)
|
||||
* or [Alt 1: Adoptium](https://adoptium.net/temurin/releases/?version=25)
|
||||
* or [Alt 2: Microsoft](https://learn.microsoft.com/en-us/java/openjdk/download).
|
||||
* **Latest Visual Studio Redist:**
|
||||
* Download via [All-in-One by Techpowerup](https://www.techpowerup.com/download/visual-c-redistributable-runtime-package-all-in-one/)
|
||||
* Or [Microsoft Visual C++ Redistributable](https://aka.ms/vc14/vc_redist.x64.exe)
|
||||
|
||||
### 🐧 Linux Prequisites
|
||||
|
||||
* Make sure you have already installed newest **GPU driver** especially proprietary NVIDIA, consult your distro docs or wiki.
|
||||
* Also make sure that your GPU can be connected to EGL, try checking it first (again, consult your distro docs or wiki) before installing Hytale game via our launcher.
|
||||
* [Not needed in update v2.2.0+] Install `libpng` package to avoid `SDL3_Image` error:
|
||||
* `libpng16-16 libpng-dev` for Ubuntu/Debian-based Distro
|
||||
* `libpng libpng-devel` for Fedora/RHEL-based Distro
|
||||
* `libpng` for Arch-based Distro
|
||||
|
||||
---
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
### 🪟 Windows Installation
|
||||
|
||||
1. **Prerequisites:** Ensure you have installed all [**Windows Prerequisites**](https://github.com/amiayweb/Hytale-F2P/tree/main?tab=readme-ov-file#-windows-prequisites) listed above.
|
||||
2. **Download:** Get the latest `Hytale-F2P-Launcher.exe` from the [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases/latest/) page.
|
||||
3. **SmartScreen Note:** Since the executable is currently unsigned, Windows may show a "Windows protected your PC" popup.
|
||||
* Click **More info**, then click **Run anyway**.
|
||||
4. **Launch:** Once installed, you can launch the app directly from your Desktop or the Start menu.
|
||||
5. **Whitelist in Windows Firewall** [#192](https://github.com/amiayweb/Hytale-F2P/issues/192#issuecomment-3803042908)
|
||||
* Open the Windows Start Menu and search for `Allow an app through Windows Firewall`
|
||||
* Click "Change settings" (you may need Admin privileges) and Locate `HytaleClient.exe` in the list.
|
||||
* Ensure both the Private and Public checkboxes are checked. Click OK to save.
|
||||
|
||||
### 🐧 Linux Installation
|
||||
|
||||
1. **Prerequisites:** Ensure you have installed all [**Linux Prerequisites**](https://github.com/amiayweb/Hytale-F2P/tree/main?tab=readme-ov-file#-linux-prequisites) above.
|
||||
2. **Download:** Choose the package that fits your distribution from the [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases/latest/) page:
|
||||
* **Universal:** `.AppImage`
|
||||
* **Arch Linux:** `.pkg.tar.zst`
|
||||
* **Fedora/RHEL/openSUSE:** `.rpm`
|
||||
* **Debian/Ubuntu:** `.deb`
|
||||
3. **Permissions & Execution:**
|
||||
* **AppImage:** Make the file executable and run it:
|
||||
```bash
|
||||
chmod +x hytale-f2p-launcher.AppImage
|
||||
./hytale-f2p-launcher.AppImage
|
||||
```
|
||||
* **Ubuntu/Debian-based or Fedora/RHEL-based:** Install the DEB/RPM:
|
||||
```bash
|
||||
# Fedora/RHEL-based
|
||||
sudo dnf install hytale-f2p-launcher.rpm
|
||||
# Debian/Ubuntu
|
||||
sudo apt install -y libasound2 libpng16-16 libpng-dev libicu76 # Not needed in v2.2.0+
|
||||
sudo dpkg -i hytale-f2p-launcher.deb
|
||||
```
|
||||
* **Arch Linux (pacman):** Install the package using:
|
||||
```bash
|
||||
# Stable Build
|
||||
sudo pacman -U hytale-f2p-launcher.pkg.tar.zst
|
||||
# Development Build
|
||||
yay -S hytale-f2p-git # or
|
||||
paru -S hytale-f2p-git
|
||||
# Manual Build
|
||||
git clone https://aur.archlinux.org/hytale-f2p-git.git
|
||||
cd hytale-f2p-git
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Make sure to adjust the filename correctly with the version and the architecture type. TIP: Use `cd` command to the package location.
|
||||
|
||||
### 🍎 macOS Installation
|
||||
|
||||
> [!NOTE]
|
||||
> Apple Silicon Users: If you are on an M1, M2, or M3 Mac, you may be prompted to install Rosetta 2 the first time you run the launcher. This is normal and required for compatibility.
|
||||
|
||||
1. **Download:** Get the latest `.dmg` file from the [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases/latest/) page.
|
||||
2. **Mount:** Double-click the `.dmg` file to open it.
|
||||
3. **Install:** Drag the **Hytale F2P Launcher** icon into your **Applications** folder.
|
||||
4. **First Run:** If macOS prevents the app from opening because it is from an "unidentified developer":
|
||||
* Open **System Settings** > **Privacy & Security**.
|
||||
* Scroll down to the **Security** section.
|
||||
* Look for the message regarding "Hytale F2P Launcher" and click **Open Anyway**.
|
||||
* Authenticate with your password and click **Open**.
|
||||
|
||||
#### **Advanced macOS: Manual Installation (.zip)**
|
||||
The `.zip` version is useful for users who prefer a portable installation or need to bypass specific permission issues.
|
||||
|
||||
1. **Extract:** Download and unzip the file to your desired location (e.g., `~/Applications`).
|
||||
2. **Remove Quarantine:** macOS often "quarantines" apps downloaded via browser. If the app won't open, open **Terminal** and run:
|
||||
```bash
|
||||
xattr -rd com.apple.quarantine /path/to/Hytale-F2P-Launcher.app
|
||||
```
|
||||
> [!TIP]
|
||||
> Type the first part of the command, then drag the app icon into the Terminal window to auto-fill the path.
|
||||
|
||||
---
|
||||
|
||||
# 📢 How to Host a Server
|
||||
|
||||
## 🌐 Host your Singleplayer Server (Online-Play Feature)
|
||||
|
||||
> [!NOTE]
|
||||
> You have to play the game to host the server. See Dedicated Server section below if you want to host it without you playing as the host.
|
||||
|
||||
1. Open your Singleplayer World
|
||||
2. Pause the game (Esc) > select Online Play > Turn on `Allow Other Players to Join` > Set password if needed > Press `Save`.
|
||||
3. Check the status `Connected via UPnP`, it means you can use the Invite Codes for your friends.
|
||||
4. If your friends can't connect to your hosted Online-Play feature OR if it's showing `"Restricted (no UPnP)`, please follow the Tailscale/Playit.gg/Radmin tutorial in [SERVER.md](SERVER.md).
|
||||
|
||||
## 🖧 Host a Dedicated Server
|
||||
|
||||
> [!NOTE]
|
||||
> If you already have the patched `HytaleServer.jar` in `HytaleF2P/{release/pre-release}/package/game/latest/Server`, you can use it to host local dedicated server. Put the `.bat`/`.sh` script from our Discord server inside your `.../latest/Server` folder.
|
||||
|
||||
> [!TIP]
|
||||
> Use services like Playit.gg, Tailscale, Radmin VPN to share UDP connection if setting up router as an admin is not possible.
|
||||
|
||||
> [!WARNING]
|
||||
> `HytaleServer.rar` file is needed to set up a server on non-playing hardware (such as VPS/server hosting). Additional: **Linux ARM64** is supported for server only, not client.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> See detailed information of setting up a server here: [SERVER.md](SERVER.md).
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for detailed Troubleshooting guide.
|
||||
|
||||
---
|
||||
|
||||
## 🔨 Building from Source
|
||||
|
||||
See [BUILD.md](docs/BUILD.md) for comprehensive build instructions.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changelog
|
||||
|
||||
### 🆕 v2.2.1
|
||||
- 👚 **Avatar Not Saving Bug Fix:** FINALLY, the long-awaited avatar saves is now working! 🙌 Show off your avatar skin in our Discord `#-media` text channel! 👀
|
||||
- 🚀 **HytaleClient Fails to Launch and Persists in Task Manager Bug Fix:** Major bug fix for all affected Windows users! No more ghost processes of `HytaleClient.exe` in Task Manager! And no more launch fail, that's hella one of an achievement 🔥 (If problem persists please create issue on Github 😢)
|
||||
- 🚦 **EPERM Bug Fix in 'Repair Game' Button:** Repair game will not produce Error Permission (EPERM) any more.
|
||||
- 🚨 **'Server Failed to Boot' Bug Fix:** Happy news for internet-limited countries (e.g. 🇷🇺 Russia, 🇹🇷 Turkey, 🇧🇷 Brazil, etc.)! The launcher now using proxy to access our patched JAR & check game version release status!🎉 Make sure you're already allow the `HytaleClient.exe` on Public & Private Windows Firewall 😉!
|
||||
- ⚡ **GPU Detection System Enhancements:** The detection system will now detect your GPU with `CimInstance` instead of `WmicObject`, which deprecated for most Windows 11 updates. Also, it's show how much your VRAM on each iGPU and dGPU! 🔍
|
||||
- ⚠️ **Failed to Deserialize Packets Bug Fix:** Shared `libzstd` library didn't get detected in Fedora/Bazzite/RHEL-based Linux Distros due to incorrect checking library order. 📑
|
||||
- 📟 **UUID Persistence Bug Fix:** Correlates to the avatar not saving bug, this fixes the persistence UUID when changing username. 🔖
|
||||
- 🌐 **Turkish Translation Fix:** 🇹🇷 Turkey players should feel at home now. 🏠
|
||||
|
||||
### 🔄 v2.2.0
|
||||
- 🔃 **Game Patches Auto-Update Improvement:** No need to install 1.5GB for every updates! Game updates now reduced to almost **~90%** (Hytale Game Update 3 to 4 only take ~150MB).
|
||||
- 🩹 **Improved Patch System Pre-Release JAR:** In previous version, only Release JAR could be patched. Now it also can be used for Pre-Release JAR!
|
||||
- 🔗 **Fix Mods Manager Issue:** Mods now can be downloaded seamlessly from the launcher, use Profiles to install your preferred mod. It will also automatically copy from selected `Profile/<profilename>` to the `Mods` folder.
|
||||
- 💾 **New User Data Location:** UserData Migration to Centralized Location. User data now preserves in `HytaleSaves` located beside `HytaleF2P` folder.
|
||||
- 🎮 **SteamDeck and Ubuntu/Debian-based Library Fix:** Replace bundled `libzstd.so` with system version to fix `glibc 2.41+` crash.
|
||||
- 🍎 **Launcher auto-update Improvement for macOS:** Fix auto-install fails on unsigned app. Added option to download the new launcher version on Github website.
|
||||
- 🌎 **New Translations**: Added France 🇲🇫, German 🇩🇪, Indonesian 🇮🇩, Russia 🇷🇺, and Swedish 🇸🇪 translations to the launcher.
|
||||
- 🔐 **Fixes Tar Vulnerability:** Updates `tar` from version `6.2.1` to `7.5.7` for vulnerability issue.
|
||||
- ⚙️ **Improved Settings Pane UI:** Settings are now shown in two columns instead of one. No more doom scrolling just to change your language.
|
||||
- ⭐ **Added Features Servers:** Don't know which one to play? Join our Featured Servers!
|
||||
- 💬 **Removed Chat Pane and Add Discord Feature:** Useless chat feature, we got Discord. Join it, NOW. Also added Discord RPC features to Github and our Discord Server. SHOW OFF TO YOUR FRIENDS.
|
||||
- 🔍 **Investigation on Avatar Not Saving Bug:** We are currently investigating this issue.
|
||||
|
||||
<details><summary>Click here to see older Changelogs</summary>
|
||||
|
||||
### 🔄 v2.1.1
|
||||
- 🛠️ **Fix Bug EPERM**: EPERM or Error Permission in creating/removing process in reinstalling is now fixed.
|
||||
- 🅰️ **Adds .pkg.tar.zst Build for Arch Users**: This Arch-package has been needed since the first release.
|
||||
- ❎ **Removes .pacman Build for Arch**: Based on the established conventions within the Arch Linux community, the file extension .pacman should not be used for package files.
|
||||
- 🌎 **New Translation**: New Polish 🇵🇱 Translation added to the Launcher.
|
||||
|
||||
### 🔄 v2.1.0
|
||||
- 🚨 **Auto-Retry Downloads and Auto-Patch Files** —
|
||||
- ⚡ **Hardware Acceleration** —
|
||||
- 🔎 **Browse CurseForge Mods** — Browsing mods now easier with our dedicated CurseForge API Key.
|
||||
- 🌎 **Fixes and Release New Translation** — Fixed 🇪🇸 🇧🇷 and added more translation for current build. Turkish 🇹🇷 language now added.
|
||||
|
||||
### 🔄 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`.
|
||||
- 🛡️ **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
|
||||
- 🐧 **Enhanced Linux Support** - Full Wayland and X11 compatibility
|
||||
- 🔄 **Multiplayer Auto-Install** - Automatic multiplayer client setup on updates (Windows)
|
||||
- 📡 **API Integration** - Real-time version checking and client management
|
||||
- 🎨 **UI Improvements** - Added contributor credits footer
|
||||
- 🔄 **Complete Launcher Overhaul** - Total redesign of the launcher architecture and interface
|
||||
- 🗂️ **Integrated Mod Manager** - Built-in mod installation, management
|
||||
- 💬 **Community Chat System** - Real-time chat for launcher users to connect and communicate
|
||||
|
||||
### 🔧 v1.0.1
|
||||
- 📁 **Custom Installation** - Choose installation directory with file browser
|
||||
- 🏠 **Always on Top** - Launcher stays visible during installation
|
||||
- 🧠 **Smart Detection** - Automatic game detection and UI adaptation
|
||||
- 🗑️ **Uninstall Feature** - Easy game removal with one click
|
||||
- 🔄 **Dynamic UI** - "INSTALL" vs "PLAY" button based on game state
|
||||
- 🛠️ **Path Management** - Proper custom directory handling
|
||||
- 💫 **UI Polish** - Improved layout and overflow prevention
|
||||
|
||||
### 🎉 v1.0.0 *(Initial Release)*
|
||||
- 🎮 **Offline Gameplay** - Play Hytale without internet connection
|
||||
- ⚡ **Auto Installation** - One-click game setup
|
||||
- ☕ **Java Management** - Automatic Java runtime handling
|
||||
- 🎨 **Modern Interface** - Clean, intuitive design
|
||||
- 🌟 **First Release** - Core launcher functionality
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Made with ❤️ by the community**
|
||||
|
||||
[](https://github.com/amiayweb/Hytale-F2P/graphs/contributors)
|
||||
|
||||
</div>
|
||||
|
||||
### 🏆 Project Creator
|
||||
- [**@amiayweb**](https://github.com/amiayweb) - *Lead Developer & Project Creator*
|
||||
- [**@Relyz1993**](https://github.com/Relyz1993) - *Server Helper & Second Developer & Project Creator*
|
||||
|
||||
### 🌟 Main 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 | Github Release Maintainer*
|
||||
- [**@ericiskoolbeans**](https://github.com/ericiskoolbeans) - *Beta Tester*
|
||||
- [**@chasem-dev**](https://github.com/chasem-dev) - *Issues fixer*
|
||||
- [**@Rahul-Sahani04**](https://github.com/Rahul-Sahani04) - *Issues fixer*
|
||||
- [**@xSamiVS**](https://github.com/xSamiVS) - *Issues fixer | Language Translator*
|
||||
|
||||
#### 🎟️ Fresh Contributors
|
||||
- [**@GreenKod**](https://github.com/GreenKod) - *Code refractor*
|
||||
- [**@Citeli-py**](https://github.com/Citeli-py) - *Linux fix & packages version in early release*
|
||||
- [**@crimera**](https://github.com/crimera) - *Generate new UUID for new username string feature*
|
||||
- [**@letha11**](https://github.com/letha11) - *CSS filename typo fix*
|
||||
- [**@colbster937**](https://github.com/colbster937) - *Icon upscaler*
|
||||
- [**@ArnavSingh77**](https://github.com/ArnavSingh77) - *Close game launcher on start feature, improve app termination behavior*
|
||||
- [**@TalesAmaral**](https://github.com/TalesAmaral) - *BUILD.md link fix in README.md*
|
||||
|
||||
#### 🌐 Language Translators
|
||||
- [**@BlackSystemCoder**](https://github.com/BlackSystemCoder) - *Russian Language Translator*
|
||||
- [**@walti0**](https://github.com/walti0) - *Polish Language Translator*
|
||||
|
||||
---
|
||||
|
||||
## 📞 Contact Information
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Questions? Ads? Collaboration? Endorsement? Other business-related?**
|
||||
Message the founders at [TG Group](https://t.me/sanhostnet) | [TG Channel](https://t.me/hf2p_og) | [Community Chat](https://chat.sanhost.net/invite/Tfz4jCK4)
|
||||
<!-- Message the founders at https://discord.gg/Fhbb9Yk5WW -->
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ⚖️ Legal Disclaimer
|
||||
|
||||
<div align="center">
|
||||
|
||||
⚠️ **Important Notice** ⚠️
|
||||
|
||||
</div>
|
||||
|
||||
This launcher is created for **educational purposes only**.
|
||||
|
||||
🏛️ **Not Official** - This is an independent fan project **not affiliated with, endorsed by, or associated with** Hypixel Studios or Hytale.
|
||||
|
||||
🛡️ **No Warranty** - This software is provided **"as is"** without any warranty of any kind.
|
||||
|
||||
📝 **Responsibility** - The authors take no responsibility for how this software is used.
|
||||
|
||||
🛑 **Takedown Policy** - If Hypixel Studios or Hytale requests removal, this project will be taken down immediately.
|
||||
|
||||
❤️ **Support Official** - Please support the official game by **purchasing** it legally when available.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**⭐ Star this project if you found it helpful! ⭐**
|
||||
|
||||
*Made with ❤️ by [@amiayweb](https://github.com/amiayweb) and the legendary contributors with amazing community*
|
||||
|
||||
[](https://www.star-history.com/#amiayweb/Hytale-F2P&type=date&legend=top-left)
|
||||
</div>
|
||||
- Community Chat: https://chat.sanhost.net/invite/Tfz4jCK4
|
||||
- Telegram Group: https://t.me/sanhostnet
|
||||
- Telegram Channel: https://t.me/hf2p_og
|
||||
|
||||
## Credits
|
||||
|
||||
Original launcher by [@amiayweb](https://github.com/amiayweb) and contributors.
|
||||
|
||||
68
SERVER.md
68
SERVER.md
@@ -1,9 +1,10 @@
|
||||
# 🎮 Hytale F2P Server Guide
|
||||
> **Deprecated:** This guide is for the legacy Hytale F2P launcher. For the new F2P Evo launcher, see [F2P Evo Server Guide](https://git.sanhost.net/sanasol/f2p-evo/src/branch/main/SERVER.md).
|
||||
|
||||
# Hytale F2P Server Guide
|
||||
|
||||
Play with friends online! This guide covers both easy in-game hosting and advanced dedicated server setup.
|
||||
|
||||
### **DOWNLOAD SERVER FILES (JAR/RAR/SCRIPTS) HERE: https://chat.sanhost.net/invite/Tfz4jCK4**
|
||||
<!-- ### **DOWNLOAD SERVER FILES (JAR/RAR/SCRIPTS) HERE: https://discord.gg/Fhbb9Yk5WW** -->
|
||||
**Need server files?** Download from [F2P Evo releases](https://git.sanhost.net/sanasol/f2p-evo/releases/latest) or join [Community Chat](https://chat.sanhost.net/invite/Tfz4jCK4).
|
||||
|
||||
**Table of Contents**
|
||||
|
||||
@@ -34,43 +35,7 @@ Play with friends online! This guide covers both easy in-game hosting and advanc
|
||||
* [10. Getting Help](#10-getting-help)
|
||||
---
|
||||
|
||||
<div align='center'>
|
||||
<h3>
|
||||
<b>
|
||||
Do you want to create Hytale Game Server with EASY SETUP, AFFORDABLE PRICE, AND 24/7 SUPPORT?
|
||||
</b>
|
||||
</h3>
|
||||
<h2>
|
||||
<b>
|
||||
<a href="https://cloudnord.net/hytale-server-hosting">CLOUDNORD</a> is the ANSWER! HF2P Server is available!
|
||||
</b>
|
||||
</h2>
|
||||
|
||||
</div>
|
||||
|
||||
**CloudNord's Hytale, Minecraft, and Game Hosting** is at the core of our Server Hosting business. Join our Gaming community and experience our large choice of premium game servers, we’ve got you covered with super high-performance hardware, fantastic support options, and powerful server hosting to build and explore your worlds without limits!
|
||||
|
||||
**Order your Hytale, Minecraft, or other game servers today!**
|
||||
Choose Java Edition, Bedrock Edition, Cross-Play, or any of our additional supported games.
|
||||
Enjoy **20% OFF** all new game servers, **available now for a limited time!** Don’t miss out.
|
||||
|
||||
### **CloudNord key hosting features include:**
|
||||
- Instant Server Setup ⚡
|
||||
- High Performance Game Servers 🚀
|
||||
- Game DDoS Protection 🛡️
|
||||
- Intelligent Game Backups 🧠
|
||||
- Quick Modpack Installer 🔧
|
||||
- Quick Plugin & Mod Installer 🧰
|
||||
- Full File Access 🗃️
|
||||
- 24/7 Support 📞 🏪
|
||||
- Powerful Game Control Server Panel 💪
|
||||
|
||||
### **Check Us Out:**
|
||||
* 👉 CloudNord Website: https://cloudnord.net/hytalef2p
|
||||
* 👉 CloudNord Discord: https://discord.gg/TYxGrmUz4Y
|
||||
* 👉 CloudNord Reviews: https://www.trustpilot.com/review/cloudnord.net?page=2&stars=5
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
### [NEW!] Play Online with Official Accounts 🆕
|
||||
|
||||
@@ -101,8 +66,7 @@ Enjoy **20% OFF** all new game servers, **available now for a limited time!** Do
|
||||
|
||||
"HytaleServer.jar", which called as "Server", functions as the place of authentication of the client that supposed to go to Hytale Official Authentication System but we managed our way to redirect it on our service (Thanks to Sanasol), handling approximately thousands of players worldwide to play this game for free.
|
||||
|
||||
Kindly support us via [our Buy Me a Coffee link](https://buymeacoffee.com/hf2p) if you think our launcher took a big part of developing this Hytale community for the love of the game itself.
|
||||
**We will always advertise, always pushing, and always asking, to every users of this launcher to purchase the original game to help the official development of Hytale**.
|
||||
**We encourage all users to purchase the official game to support its development.**
|
||||
|
||||
### Server Directory Location
|
||||
|
||||
@@ -248,7 +212,7 @@ Free tunneling service - only the host needs to install it:
|
||||
* Right-click file > Properties > Turn on 'Executable as a Program' | or `chmod +x playit-linux-amd64` on terminal
|
||||
* Run by double-clicking the file or `./playit-linux-amd64` via terminal
|
||||
5. Open the URL/link by `Ctrl+Click` it. If unable, select the URL, then Right-Click to Copy (`Ctrl+Shift+C` for Linux) then Paste the URL into your browser to link it with your created account.
|
||||
6. Once it done, download the `run_server_with_tokens (1)` script file (`.BAT` for Windows, `.SH` for Linux) from our Discord server > channel `#open-public-server`
|
||||
6. Download `start.bat` (Windows) or `start.sh` (Linux/macOS) from the [server/ directory](server/)
|
||||
7. Put the script file to the `Server` folder in `HytaleF2P` directory (`%localappdata%\HytaleF2P\release\package\game\latest\Server`)
|
||||
8. Rename the script file to `run_server_with_tokens` to make it easier if you run it with Terminal, then do Method A or B.
|
||||
9. If you put it in `Server` folder in `HytaleF2P` launcher, change `ASSETS_PATH="${ASSETS_PATH:-./Assets.zip}"` inside the script to be `ASSETS_PATH="${ASSETS_PATH:-../Assets.zip}"`. NOTICE THE `./` and `../` DIFFERENCE.
|
||||
@@ -283,9 +247,8 @@ For 24/7 servers, custom configurations, or hosting on a VPS/dedicated machine.
|
||||
2. `Assets.zip`
|
||||
3. `run_scripts_with_token.bat` for Windows or `run_scripts_with_token.sh` for macOS/Linux
|
||||
|
||||
> [!NOTE]
|
||||
> The `HytaleServer.rar` available on our Discord Server (`#open-public-server` channel; typo on the Discord, not `zip`) includes all of the prequisites.
|
||||
> Unfortunately, the JAR inside the RAR isn't updated so you'll need to download the JAR from the link on Discord.
|
||||
> [!TIP]
|
||||
> The easiest way is to use the one-click scripts from the [server/](server/) directory. They handle downloading everything automatically.
|
||||
|
||||
> [!TIP]
|
||||
> You can copy `Assets.zip` generated from the launcher to be used for the dedicated server. It's located in `HytaleF2P/release/package/game/latest`.
|
||||
@@ -558,10 +521,10 @@ docker run -d \
|
||||
-e HYTALE_AUTH_DOMAIN=auth.sanasol.ws \
|
||||
-e HYTALE_SERVER_NAME="My Server" \
|
||||
-e JVM_XMX=8G \
|
||||
ghcr.io/hybrowse/hytale-server-docker:latest
|
||||
ghcr.io/sanasol/hytale-server-docker:latest
|
||||
```
|
||||
|
||||
See [Docker documentation](https://github.com/Hybrowse/hytale-server-docker) for details.
|
||||
See [Docker documentation](https://github.com/sanasol/hytale-server-docker) for details.
|
||||
|
||||
---
|
||||
|
||||
@@ -576,9 +539,12 @@ See [Docker documentation](https://github.com/Hybrowse/hytale-server-docker) for
|
||||
|
||||
# Credits
|
||||
|
||||
- Hytale F2P Project
|
||||
- [Hybrowse Docker Image](https://github.com/Hybrowse/hytale-server-docker)
|
||||
- Auth Server: sanasol.ws
|
||||
- F2P Evo Project
|
||||
- [Server Docker Image](https://github.com/sanasol/hytale-server-docker)
|
||||
- [Auth Server](https://github.com/sanasol/hytale-auth-server)
|
||||
- Auth domain: auth.sanasol.ws
|
||||
|
||||
**Need help?** [Community Chat](https://chat.sanhost.net/invite/Tfz4jCK4) | [TG Group](https://t.me/sanhostnet) | [TG Channel](https://t.me/hf2p_og)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
> **Deprecated:** This guide is for the legacy Hytale F2P launcher. For the new F2P Evo launcher, see [F2P Evo Troubleshooting](https://git.sanhost.net/sanasol/f2p-evo/src/branch/main/TROUBLESHOOTING.md).
|
||||
|
||||
# Hytale F2P Launcher - Troubleshooting Guide
|
||||
|
||||
This guide covers common issues and their solutions. If your issue isn't listed here, please check [existing issues](https://github.com/amiayweb/Hytale-F2P/issues) or join our [TG Group](https://t.me/sanhostnet) | [TG Channel](https://t.me/hf2p_og) | [Community Chat](https://chat.sanhost.net/invite/Tfz4jCK4).
|
||||
<!-- Discord: https://discord.gg/Fhbb9Yk5WW -->
|
||||
This guide covers common issues and their solutions. If your issue isn't listed here, please check [existing issues](https://git.sanhost.net/sanasol/f2p-evo/issues) or join [Community Chat](https://chat.sanhost.net/invite/Tfz4jCK4) | [TG Group](https://t.me/sanhostnet) | [TG Channel](https://t.me/hf2p_og).
|
||||
|
||||
---
|
||||
|
||||
@@ -206,7 +207,7 @@ domain: 'SQRLCodeSignatureErrorDomain'
|
||||
```
|
||||
|
||||
**Solution - Manual update:**
|
||||
1. Download the latest version manually from [Releases](https://github.com/amiayweb/Hytale-F2P/releases/latest)
|
||||
1. Download the latest version manually from [Releases](https://git.sanhost.net/sanasol/f2p-evo/releases/latest)
|
||||
2. Backup your data first (see [Backup Locations](#backup-locations))
|
||||
3. Install the fresh download
|
||||
|
||||
@@ -437,9 +438,8 @@ Game sessions have a 10-hour TTL. This is by design for security.
|
||||
|
||||
If your issue isn't resolved by this guide:
|
||||
|
||||
1. **Check existing issues:** [GitHub Issues](https://github.com/amiayweb/Hytale-F2P/issues)
|
||||
2. **Join Community:** [TG Group](https://t.me/sanhostnet) | [TG Channel](https://t.me/hf2p_og) | [Chat](https://chat.sanhost.net/invite/Tfz4jCK4)
|
||||
<!-- Discord: https://discord.gg/Fhbb9Yk5WW -->
|
||||
1. **Check existing issues:** [Issues](https://git.sanhost.net/sanasol/f2p-evo/issues)
|
||||
2. **Join the community:** [Community Chat](https://chat.sanhost.net/invite/Tfz4jCK4) | [TG Group](https://t.me/sanhostnet) | [TG Channel](https://t.me/hf2p_og)
|
||||
3. **Open a new issue** with:
|
||||
- Your operating system and version
|
||||
- Launcher version
|
||||
|
||||
@@ -529,7 +529,7 @@ function getAllUuidMappingsArray() {
|
||||
* Validates UUID format before saving
|
||||
* Preserves original case of username
|
||||
*/
|
||||
function setUuidForUser(username, uuid) {
|
||||
function setUuidForUser(username, uuid, { force = false } = {}) {
|
||||
const { validate: validateUuid } = require('uuid');
|
||||
|
||||
if (!username || typeof username !== 'string' || !username.trim()) {
|
||||
@@ -543,15 +543,29 @@ function setUuidForUser(username, uuid) {
|
||||
const displayName = username.trim();
|
||||
const normalizedLookup = displayName.toLowerCase();
|
||||
|
||||
// 1. Update UUID store (source of truth)
|
||||
// 1. Check for existing entries — reject overwrite unless forced
|
||||
migrateUuidStoreIfNeeded();
|
||||
const uuidStore = loadUuidStore();
|
||||
const storeKey = Object.keys(uuidStore).find(k => k.toLowerCase() === normalizedLookup);
|
||||
if (storeKey && uuidStore[storeKey] !== uuid && !force) {
|
||||
console.log(`[Config] Rejected UUID overwrite for "${displayName}": existing ${uuidStore[storeKey]}, attempted ${uuid}`);
|
||||
return { success: false, error: 'duplicate', existingUuid: uuidStore[storeKey] };
|
||||
}
|
||||
// Check if UUID already used by a different name
|
||||
if (!force) {
|
||||
const existingByUuid = Object.entries(uuidStore).find(([k, v]) => v.toLowerCase() === uuid.toLowerCase() && k.toLowerCase() !== normalizedLookup);
|
||||
if (existingByUuid) {
|
||||
console.log(`[Config] Rejected duplicate UUID for "${displayName}": UUID ${uuid} already used by "${existingByUuid[0]}"`);
|
||||
return { success: false, error: 'uuid_in_use', existingUsername: existingByUuid[0] };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Update UUID store (source of truth)
|
||||
if (storeKey) delete uuidStore[storeKey];
|
||||
uuidStore[displayName] = uuid;
|
||||
saveUuidStore(uuidStore);
|
||||
|
||||
// 2. Update config.json (backward compat)
|
||||
// 3. Update config.json (backward compat)
|
||||
const config = loadConfig();
|
||||
const userUuids = config.userUuids || {};
|
||||
const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
|
||||
@@ -560,7 +574,7 @@ function setUuidForUser(username, uuid) {
|
||||
saveConfig({ userUuids });
|
||||
|
||||
console.log(`[Config] UUID set for "${displayName}": ${uuid}`);
|
||||
return uuid;
|
||||
return { success: true, uuid };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -619,7 +633,7 @@ function resetCurrentUserUuid() {
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const newUuid = uuidv4();
|
||||
|
||||
return setUuidForUser(username, newUuid);
|
||||
return setUuidForUser(username, newUuid, { force: true });
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -1073,6 +1087,48 @@ function _generateWindowsWrapper(stripFlags, alwaysArgs, serverArgs) {
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MATCHA SOCIAL AUTH
|
||||
// =============================================================================
|
||||
|
||||
function saveMatchaToken(token) {
|
||||
saveConfig({ matchaToken: token || null });
|
||||
}
|
||||
|
||||
function loadMatchaToken() {
|
||||
const config = loadConfig();
|
||||
return config.matchaToken || null;
|
||||
}
|
||||
|
||||
function saveMatchaHandle(handle) {
|
||||
saveConfig({ matchaHandle: handle || null });
|
||||
}
|
||||
|
||||
function loadMatchaHandle() {
|
||||
const config = loadConfig();
|
||||
return config.matchaHandle || null;
|
||||
}
|
||||
|
||||
function saveMatchaUserId(id) {
|
||||
saveConfig({ matchaUserId: id || null });
|
||||
}
|
||||
|
||||
function loadMatchaUserId() {
|
||||
const config = loadConfig();
|
||||
return config.matchaUserId || null;
|
||||
}
|
||||
|
||||
function clearMatchaAuth() {
|
||||
const config = loadConfig();
|
||||
delete config.matchaToken;
|
||||
delete config.matchaUserId;
|
||||
delete config.matchaHandle;
|
||||
const data = JSON.stringify(config, null, 2);
|
||||
fs.writeFileSync(CONFIG_TEMP, data, 'utf8');
|
||||
if (fs.existsSync(CONFIG_FILE)) fs.copyFileSync(CONFIG_FILE, CONFIG_BACKUP);
|
||||
fs.renameSync(CONFIG_TEMP, CONFIG_FILE);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXPORTS
|
||||
// =============================================================================
|
||||
@@ -1148,6 +1204,15 @@ module.exports = {
|
||||
resetWrapperConfig,
|
||||
generateWrapperScript,
|
||||
|
||||
// Matcha Social
|
||||
saveMatchaToken,
|
||||
loadMatchaToken,
|
||||
saveMatchaHandle,
|
||||
loadMatchaHandle,
|
||||
saveMatchaUserId,
|
||||
loadMatchaUserId,
|
||||
clearMatchaAuth,
|
||||
|
||||
// Constants
|
||||
CONFIG_FILE,
|
||||
UUID_STORE_FILE
|
||||
|
||||
@@ -43,24 +43,51 @@ try {
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Fetch tokens from the auth server (properly signed with server's Ed25519 key)
|
||||
async function fetchAuthTokens(uuid, name) {
|
||||
async function fetchAuthTokens(uuid, name, password) {
|
||||
const authServerUrl = getAuthServerUrl();
|
||||
try {
|
||||
console.log(`Fetching auth tokens from ${authServerUrl}/game-session/child`);
|
||||
|
||||
const bodyData = {
|
||||
uuid: uuid,
|
||||
name: name,
|
||||
scopes: ['hytale:server', 'hytale:client']
|
||||
};
|
||||
if (password) bodyData.password = password;
|
||||
|
||||
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']
|
||||
})
|
||||
body: JSON.stringify(bodyData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errBody = await response.json().catch(() => ({}));
|
||||
if (response.status === 401 && errBody.password_required) {
|
||||
const err = new Error('Password required');
|
||||
err.passwordRequired = true;
|
||||
err.attemptsRemaining = errBody.attemptsRemaining;
|
||||
throw err;
|
||||
}
|
||||
if (response.status === 429) {
|
||||
const err = new Error('Too many failed attempts. Try again later.');
|
||||
err.lockedOut = true;
|
||||
err.lockoutSeconds = errBody.lockoutSeconds;
|
||||
throw err;
|
||||
}
|
||||
if (response.status === 403 && errBody.username_taken) {
|
||||
const err = new Error('This username is reserved by another player who has set a password. Please use a different name.');
|
||||
err.usernameTaken = true;
|
||||
throw err;
|
||||
}
|
||||
if (response.status === 403 && errBody.name_locked) {
|
||||
const err = new Error(`This UUID is locked to username "${errBody.registeredName}". Change your identity name to match.`);
|
||||
err.nameLocked = true;
|
||||
err.registeredName = errBody.registeredName;
|
||||
throw err;
|
||||
}
|
||||
throw new Error(`Auth server returned ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -77,10 +104,12 @@ async function fetchAuthTokens(uuid, name) {
|
||||
if (payload.username && payload.username !== name && name !== 'Player') {
|
||||
console.warn(`[Auth] Token username mismatch: token has "${payload.username}", expected "${name}". Retrying...`);
|
||||
// Retry once with explicit name
|
||||
const retryBody = { uuid: uuid, name: name, scopes: ['hytale:server', 'hytale:client'] };
|
||||
if (password) retryBody.password = password;
|
||||
const retryResponse = 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'] })
|
||||
body: JSON.stringify(retryBody)
|
||||
});
|
||||
if (retryResponse.ok) {
|
||||
const retryData = await retryResponse.json();
|
||||
@@ -99,6 +128,10 @@ async function fetchAuthTokens(uuid, name) {
|
||||
console.log('Auth tokens received from server');
|
||||
return { identityToken, sessionToken };
|
||||
} catch (error) {
|
||||
// Re-throw authentication errors — must not fall back to local tokens
|
||||
if (error.passwordRequired || error.lockedOut || error.usernameTaken || error.nameLocked) {
|
||||
throw error;
|
||||
}
|
||||
console.error('Failed to fetch auth tokens:', error.message);
|
||||
// Fallback to local generation if server unavailable
|
||||
return generateLocalTokens(uuid, name);
|
||||
@@ -147,7 +180,7 @@ function generateLocalTokens(uuid, name) {
|
||||
};
|
||||
}
|
||||
|
||||
async function launchGame(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
|
||||
async function launchGame(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null, options = {}) {
|
||||
// ==========================================================================
|
||||
// CACHE INVALIDATION: Clear proxyClient module cache to force fresh .env load
|
||||
// This prevents stale cached values from affecting multiple launch attempts
|
||||
@@ -256,11 +289,12 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
|
||||
const uuid = getUuidForUser(playerName);
|
||||
console.log(`[Launcher] UUID for "${playerName}": ${uuid} (verify this stays constant across launches)`);
|
||||
|
||||
// Fetch tokens from auth server
|
||||
// Fetch tokens from auth server (with password if provided)
|
||||
if (progressCallback) {
|
||||
progressCallback('Fetching authentication tokens...', null, null, null, null);
|
||||
}
|
||||
const { identityToken, sessionToken } = await fetchAuthTokens(uuid, playerName);
|
||||
const launchPassword = options?.password || null;
|
||||
const { identityToken, sessionToken } = await fetchAuthTokens(uuid, playerName, launchPassword);
|
||||
|
||||
// Patch client and server binaries to use custom auth server (BEFORE signing on macOS)
|
||||
// FORCE patch on every launch to ensure consistency
|
||||
@@ -411,6 +445,17 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
|
||||
|
||||
const env = { ...process.env };
|
||||
|
||||
// Linux: Add Client directory to LD_LIBRARY_PATH so the dynamic linker can find
|
||||
// bundled native libraries (e.g. libSDL3_image.so.0). The .NET DllImport only tries
|
||||
// bare names like "SDL3_image.so" which don't match versioned .so.0 files.
|
||||
// LD_LIBRARY_PATH lets dlopen() find them via standard library resolution.
|
||||
if (process.platform === 'linux') {
|
||||
const clientDir = path.dirname(clientPath);
|
||||
const existing = env.LD_LIBRARY_PATH || '';
|
||||
env.LD_LIBRARY_PATH = existing ? `${clientDir}:${existing}` : clientDir;
|
||||
console.log(`Linux: LD_LIBRARY_PATH includes ${clientDir}`);
|
||||
}
|
||||
|
||||
const waylandEnv = setupWaylandEnvironment();
|
||||
Object.assign(env, waylandEnv);
|
||||
const gpuEnv = setupGpuEnvironment(gpuPreference);
|
||||
@@ -482,11 +527,15 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
|
||||
}
|
||||
}
|
||||
|
||||
// DualAuth Agent: DISABLED for debug - testing fastutil classloader issue
|
||||
// TODO: re-enable after testing
|
||||
// DualAuth Agent: Set JAVA_TOOL_OPTIONS so java picks up -javaagent: flag
|
||||
// This enables runtime auth patching without modifying the server JAR
|
||||
const agentJar = path.join(gameLatest, 'Server', 'dualauth-agent.jar');
|
||||
if (fs.existsSync(agentJar)) {
|
||||
console.log('DualAuth Agent: SKIPPED (debug build - fastutil classloader test)');
|
||||
const agentFlag = `-javaagent:"${agentJar}"`;
|
||||
env.JAVA_TOOL_OPTIONS = env.JAVA_TOOL_OPTIONS
|
||||
? `${env.JAVA_TOOL_OPTIONS} ${agentFlag}`
|
||||
: agentFlag;
|
||||
console.log('DualAuth Agent: enabled via JAVA_TOOL_OPTIONS');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -574,7 +623,7 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
|
||||
}
|
||||
}
|
||||
|
||||
async function launchGameWithVersionCheck(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
|
||||
async function launchGameWithVersionCheck(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null, options = {}) {
|
||||
try {
|
||||
// ==========================================================================
|
||||
// PRE-LAUNCH VALIDATION: Check username is configured
|
||||
@@ -647,7 +696,7 @@ async function launchGameWithVersionCheck(playerNameOverride = null, progressCal
|
||||
progressCallback('Launching game...', 80, null, null, null);
|
||||
}
|
||||
|
||||
const launchResult = await launchGame(playerNameOverride, progressCallback, javaPathOverride, installPathOverride, gpuPreference, branch);
|
||||
const launchResult = await launchGame(playerNameOverride, progressCallback, javaPathOverride, installPathOverride, gpuPreference, branch, options);
|
||||
|
||||
// Ensure we always return a result
|
||||
if (!launchResult) {
|
||||
@@ -661,6 +710,10 @@ async function launchGameWithVersionCheck(playerNameOverride = null, progressCal
|
||||
if (progressCallback) {
|
||||
progressCallback(`Error: ${error.message}`, -1, null, null, null);
|
||||
}
|
||||
// Re-throw authentication errors so IPC handler can return proper flags
|
||||
if (error.passwordRequired || error.lockedOut || error.usernameTaken || error.nameLocked) {
|
||||
throw error;
|
||||
}
|
||||
// Always return an error response instead of throwing
|
||||
return { success: false, error: error.message || 'Unknown launch error' };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const {
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
getModsPath
|
||||
const {
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
getModsPath,
|
||||
loadVersionBranch,
|
||||
saveVersionBranch,
|
||||
loadVersionClient,
|
||||
saveVersionClient
|
||||
} = require('../core/config');
|
||||
|
||||
// Lazy-load modManager to avoid circular deps, or keep imports structured.
|
||||
@@ -39,11 +43,13 @@ class ProfileManager {
|
||||
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 || '',
|
||||
versionBranch: config.version_branch || 'release',
|
||||
versionClient: config.version_client || null,
|
||||
gameOptions: {
|
||||
minMemory: '1G',
|
||||
maxMemory: '4G',
|
||||
@@ -73,13 +79,16 @@ class ProfileManager {
|
||||
const config = loadConfig();
|
||||
const id = uuidv4();
|
||||
|
||||
// New profiles inherit the current branch/version
|
||||
const newProfile = {
|
||||
id,
|
||||
name: name.trim(),
|
||||
created: new Date().toISOString(),
|
||||
lastUsed: null,
|
||||
mods: [], // Start with no mods enabled
|
||||
javaPath: '',
|
||||
javaPath: '',
|
||||
versionBranch: loadVersionBranch(),
|
||||
versionClient: loadVersionClient(),
|
||||
gameOptions: {
|
||||
minMemory: '1G',
|
||||
maxMemory: '4G',
|
||||
@@ -128,20 +137,35 @@ class ProfileManager {
|
||||
}
|
||||
|
||||
console.log(`[ProfileManager] Switching to profile: ${config.profiles[id].name} (${id})`);
|
||||
|
||||
// 1. Update config first
|
||||
|
||||
// Save current branch/version to the outgoing profile
|
||||
const oldId = config.activeProfileId;
|
||||
if (oldId && config.profiles[oldId]) {
|
||||
config.profiles[oldId].versionBranch = loadVersionBranch();
|
||||
config.profiles[oldId].versionClient = loadVersionClient();
|
||||
}
|
||||
|
||||
// 1. Update config
|
||||
config.profiles[id].lastUsed = new Date().toISOString();
|
||||
saveConfig({
|
||||
saveConfig({
|
||||
activeProfileId: id,
|
||||
profiles: config.profiles
|
||||
profiles: config.profiles
|
||||
});
|
||||
|
||||
// 2. Trigger Mod Sync
|
||||
// We need to require this here to ensure it uses the *newly saved* active profile ID
|
||||
// 2. Restore branch/version from the new profile
|
||||
const newProfile = config.profiles[id];
|
||||
if (newProfile.versionBranch) {
|
||||
saveVersionBranch(newProfile.versionBranch);
|
||||
}
|
||||
if (newProfile.versionClient !== undefined) {
|
||||
saveVersionClient(newProfile.versionClient);
|
||||
}
|
||||
|
||||
// 3. Trigger Mod Sync
|
||||
const { syncModsForCurrentProfile } = require('./modManager');
|
||||
await syncModsForCurrentProfile();
|
||||
|
||||
return config.profiles[id];
|
||||
return newProfile;
|
||||
}
|
||||
|
||||
deleteProfile(id) {
|
||||
@@ -177,7 +201,7 @@ class ProfileManager {
|
||||
}
|
||||
|
||||
// Safety checks on updates
|
||||
const allowedFields = ['name', 'javaPath', 'gameOptions', 'mods'];
|
||||
const allowedFields = ['name', 'javaPath', 'gameOptions', 'mods', 'versionBranch', 'versionClient'];
|
||||
const sanitizedUpdates = {};
|
||||
|
||||
Object.keys(updates).forEach(key => {
|
||||
|
||||
529
backend/services/matchaService.js
Normal file
529
backend/services/matchaService.js
Normal file
@@ -0,0 +1,529 @@
|
||||
const axios = require('axios');
|
||||
const WebSocket = require('ws');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { loadConfig, saveConfig } = require('../core/config');
|
||||
|
||||
const MATCHA_BASE = 'https://butter.lat';
|
||||
const MATCHA_API = `${MATCHA_BASE}/api/matcha`;
|
||||
const MATCHA_WS = 'wss://butter.lat/api/matcha/ws';
|
||||
|
||||
class MatchaService {
|
||||
constructor() {
|
||||
this.token = null;
|
||||
this.user = null;
|
||||
this.ws = null;
|
||||
this.wsConnected = false;
|
||||
this.mainWindow = null;
|
||||
this.heartbeatInterval = null;
|
||||
this.reconnectTimeout = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.lastMessageSentAt = 0;
|
||||
this.gameRunning = false;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// LIFECYCLE
|
||||
// =========================================================================
|
||||
|
||||
init(mainWindow) {
|
||||
this.mainWindow = mainWindow;
|
||||
this.token = this._loadToken();
|
||||
if (this.token) {
|
||||
this._connectWs();
|
||||
this._startHeartbeat();
|
||||
// Load cached user info
|
||||
const config = loadConfig();
|
||||
if (config.matchaUserId && config.matchaHandle) {
|
||||
this.user = { id: config.matchaUserId, handle: config.matchaHandle };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Best-effort offline heartbeat
|
||||
if (this.token) {
|
||||
this.sendHeartbeat('offline').catch(() => {});
|
||||
}
|
||||
this._stopHeartbeat();
|
||||
this._disconnectWs();
|
||||
this.mainWindow = null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TOKEN MANAGEMENT
|
||||
// =========================================================================
|
||||
|
||||
_loadToken() {
|
||||
const config = loadConfig();
|
||||
return config.matchaToken || null;
|
||||
}
|
||||
|
||||
_saveToken(token) {
|
||||
this.token = token;
|
||||
saveConfig({ matchaToken: token });
|
||||
}
|
||||
|
||||
_saveUser(user) {
|
||||
this.user = user;
|
||||
if (user) {
|
||||
saveConfig({ matchaUserId: user.id, matchaHandle: user.handle });
|
||||
}
|
||||
}
|
||||
|
||||
_clearAuth() {
|
||||
this.token = null;
|
||||
this.user = null;
|
||||
this._stopHeartbeat();
|
||||
this._disconnectWs();
|
||||
// Set to null — saveConfig merges, and JSON.stringify preserves null values,
|
||||
// but this effectively marks them as cleared for _loadToken() checks
|
||||
saveConfig({ matchaToken: null, matchaUserId: null, matchaHandle: null });
|
||||
}
|
||||
|
||||
_authHeaders() {
|
||||
return this.token ? { Authorization: `Bearer ${this.token}` } : {};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// AUTH
|
||||
// =========================================================================
|
||||
|
||||
async register(username, password, password2) {
|
||||
try {
|
||||
const res = await axios.post(`${MATCHA_API}/register`, {
|
||||
username, password, password2, deferCreate: true
|
||||
});
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async confirmRegistration(pendingId, proofId) {
|
||||
try {
|
||||
const res = await axios.post(`${MATCHA_API}/register/confirm`, {
|
||||
pendingId, proofId
|
||||
});
|
||||
if (res.data.token) {
|
||||
this._saveToken(res.data.token);
|
||||
if (res.data.user) this._saveUser(res.data.user);
|
||||
this._connectWs();
|
||||
this._startHeartbeat();
|
||||
}
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async login(handle, password) {
|
||||
try {
|
||||
const res = await axios.post(`${MATCHA_API}/login`, { handle, password });
|
||||
if (res.data.token) {
|
||||
this._saveToken(res.data.token);
|
||||
if (res.data.user) this._saveUser(res.data.user);
|
||||
this._connectWs();
|
||||
this._startHeartbeat();
|
||||
}
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.sendHeartbeat('offline').catch(() => {});
|
||||
this._clearAuth();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
getAuthState() {
|
||||
return {
|
||||
authenticated: !!this.token,
|
||||
user: this.user,
|
||||
wsConnected: this.wsConnected
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PROFILE
|
||||
// =========================================================================
|
||||
|
||||
async getMe() {
|
||||
try {
|
||||
const res = await axios.get(`${MATCHA_API}/me`, { headers: this._authHeaders() });
|
||||
if (res.data?.user) this._saveUser({ id: res.data.user.id, handle: res.data.user.handle });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async getUser(userId) {
|
||||
try {
|
||||
const res = await axios.get(`${MATCHA_API}/users/${encodeURIComponent(userId)}`, { headers: this._authHeaders() });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FRIENDS
|
||||
// =========================================================================
|
||||
|
||||
async getFriends() {
|
||||
try {
|
||||
const res = await axios.get(`${MATCHA_API}/friends`, { headers: this._authHeaders() });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async sendFriendRequest(handle) {
|
||||
try {
|
||||
const res = await axios.post(`${MATCHA_API}/friends/request`, { toHandle: handle }, { headers: this._authHeaders() });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async acceptFriend(requestId) {
|
||||
try {
|
||||
const res = await axios.post(`${MATCHA_API}/friends/request/accept`, { id: requestId }, { headers: this._authHeaders() });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async rejectFriend(requestId) {
|
||||
try {
|
||||
const res = await axios.post(`${MATCHA_API}/friends/request/reject`, { id: requestId }, { headers: this._authHeaders() });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async cancelFriendRequest(requestId) {
|
||||
try {
|
||||
const res = await axios.post(`${MATCHA_API}/friends/request/cancel`, { id: requestId }, { headers: this._authHeaders() });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async removeFriend(friendId) {
|
||||
try {
|
||||
const res = await axios.post(`${MATCHA_API}/friends/remove`, { friendId }, { headers: this._authHeaders() });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MESSAGES
|
||||
// =========================================================================
|
||||
|
||||
async getMessages(withTarget, cursor, after) {
|
||||
try {
|
||||
const params = { with: withTarget };
|
||||
if (cursor) params.cursor = cursor;
|
||||
if (after) params.after = after;
|
||||
const res = await axios.get(`${MATCHA_API}/messages`, { headers: this._authHeaders(), params });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(to, body, replyTo) {
|
||||
// Enforce 800ms throttle
|
||||
const now = Date.now();
|
||||
const elapsed = now - this.lastMessageSentAt;
|
||||
if (elapsed < 800) {
|
||||
return { ok: false, error: 'Please wait before sending another message' };
|
||||
}
|
||||
this.lastMessageSentAt = now;
|
||||
|
||||
// Try WebSocket first
|
||||
if (this.wsConnected && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
const msg = { type: 'send', to, body };
|
||||
if (replyTo) msg.replyTo = replyTo;
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
return { ok: true, data: { sent: true, via: 'ws' } };
|
||||
}
|
||||
|
||||
// Fallback to HTTP
|
||||
try {
|
||||
const payload = { to, body };
|
||||
if (replyTo) payload.replyTo = replyTo;
|
||||
const res = await axios.post(`${MATCHA_API}/messages/send`, payload, { headers: this._authHeaders() });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMessage(messageId) {
|
||||
try {
|
||||
const res = await axios.post(`${MATCHA_API}/messages/${messageId}/delete`, {}, { headers: this._authHeaders() });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// UNREAD
|
||||
// =========================================================================
|
||||
|
||||
async getUnread() {
|
||||
try {
|
||||
const res = await axios.get(`${MATCHA_API}/unread`, { headers: this._authHeaders() });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async clearUnread(withTarget) {
|
||||
try {
|
||||
const res = await axios.post(`${MATCHA_API}/unread/clear`, { with: withTarget }, { headers: this._authHeaders() });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HEARTBEAT
|
||||
// =========================================================================
|
||||
|
||||
async sendHeartbeat(state) {
|
||||
if (!this.token) return;
|
||||
try {
|
||||
await axios.post(`${MATCHA_API}/heartbeat`, { state: state || 'online' }, { headers: this._authHeaders() });
|
||||
} catch (err) {
|
||||
console.log('[Matcha] Heartbeat failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
_startHeartbeat() {
|
||||
this._stopHeartbeat();
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
const state = this.gameRunning ? 'in_game' : 'online';
|
||||
this.sendHeartbeat(state);
|
||||
}, 30000);
|
||||
// Send initial heartbeat
|
||||
this.sendHeartbeat('online');
|
||||
}
|
||||
|
||||
_stopHeartbeat() {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
setGameRunning(running) {
|
||||
this.gameRunning = running;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// AVATAR
|
||||
// =========================================================================
|
||||
|
||||
async uploadAvatar(filePath, mode) {
|
||||
try {
|
||||
const crypto = require('crypto');
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
if (fileBuffer.length > 1024 * 1024) {
|
||||
return { ok: false, error: 'Avatar too large (max 1MB)' };
|
||||
}
|
||||
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
const endpoint = mode === 'custom' ? `${MATCHA_API}/avatar/custom` : `${MATCHA_API}/avatar`;
|
||||
const res = await axios.post(endpoint, fileBuffer, {
|
||||
headers: {
|
||||
...this._authHeaders(),
|
||||
'Content-Type': 'image/png',
|
||||
'x-avatar-hash': hash,
|
||||
'x-avatar-enable': '1',
|
||||
'x-avatar-force': '1',
|
||||
'Cache-Control': 'no-store'
|
||||
},
|
||||
maxContentLength: 1024 * 1024
|
||||
});
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAvatar() {
|
||||
try {
|
||||
const res = await axios.delete(`${MATCHA_API}/avatar`, { headers: this._authHeaders() });
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return this._handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// WEBSOCKET
|
||||
// =========================================================================
|
||||
|
||||
_connectWs() {
|
||||
if (this.ws) this._disconnectWs();
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(MATCHA_WS);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log('[Matcha] WebSocket connected');
|
||||
this.reconnectAttempts = 0;
|
||||
// Authenticate
|
||||
if (this.token) {
|
||||
this.ws.send(JSON.stringify({ type: 'auth', token: this.token }));
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('message', (raw) => {
|
||||
try {
|
||||
const data = JSON.parse(raw.toString());
|
||||
this._handleWsMessage(data);
|
||||
} catch (err) {
|
||||
console.error('[Matcha] WS parse error:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', (code) => {
|
||||
console.log('[Matcha] WebSocket closed:', code);
|
||||
this.wsConnected = false;
|
||||
this._sendToRenderer('matcha:ws:disconnected');
|
||||
|
||||
// Handle ban
|
||||
if (code === 4003) {
|
||||
this._sendToRenderer('matcha:ws:banned', { reason: 'Account banned' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-reconnect if we have a token
|
||||
if (this.token && code !== 4003) {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
console.error('[Matcha] WS error:', err.message);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Matcha] WS connect failed:', err.message);
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
_disconnectWs() {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch (e) {}
|
||||
this.ws = null;
|
||||
}
|
||||
this.wsConnected = false;
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
if (this.reconnectTimeout) return;
|
||||
// Exponential backoff capped at 30s, no hard limit (matches Butter's infinite reconnect)
|
||||
const delay = Math.min(2000 * Math.pow(2, this.reconnectAttempts), 30000);
|
||||
this.reconnectAttempts++;
|
||||
// Notify renderer after several failures so it can show a banner
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
this._sendToRenderer('matcha:ws:max-retries');
|
||||
}
|
||||
console.log(`[Matcha] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.reconnectTimeout = null;
|
||||
if (this.token) this._connectWs();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
_handleWsMessage(data) {
|
||||
switch (data.type) {
|
||||
case 'authed':
|
||||
this.wsConnected = true;
|
||||
if (data.user) this._saveUser(data.user);
|
||||
this._sendToRenderer('matcha:ws:connected', { user: this.user });
|
||||
break;
|
||||
case 'message':
|
||||
console.log('[Matcha] WS message received:', JSON.stringify(data).substring(0, 200));
|
||||
this._sendToRenderer('matcha:ws:message', data);
|
||||
break;
|
||||
case 'message_deleted':
|
||||
this._sendToRenderer('matcha:ws:message-deleted', data);
|
||||
break;
|
||||
case 'avatar_updated':
|
||||
this._sendToRenderer('matcha:ws:avatar-updated', data);
|
||||
break;
|
||||
case 'banned':
|
||||
this._sendToRenderer('matcha:ws:banned', data);
|
||||
break;
|
||||
case 'announcement':
|
||||
this._sendToRenderer('matcha:ws:announcement', data);
|
||||
break;
|
||||
case 'error':
|
||||
console.log('[Matcha] WS error:', data.message || data.error || JSON.stringify(data));
|
||||
// If auth error, treat as ban/disconnect
|
||||
if (data.message === 'Not authed' || data.error === 'Not authed') {
|
||||
this._clearAuth();
|
||||
this._sendToRenderer('matcha:ws:disconnected');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.log('[Matcha] Unknown WS message type:', data.type);
|
||||
}
|
||||
}
|
||||
|
||||
manualReconnect() {
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.token) this._connectWs();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPERS
|
||||
// =========================================================================
|
||||
|
||||
_sendToRenderer(channel, data) {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
|
||||
_handleError(err) {
|
||||
if (err.response) {
|
||||
const status = err.response.status;
|
||||
const msg = err.response.data?.error || err.response.data?.message || err.message;
|
||||
if (status === 401) {
|
||||
// Token expired or invalid
|
||||
this._clearAuth();
|
||||
this._sendToRenderer('matcha:ws:disconnected');
|
||||
}
|
||||
return { ok: false, error: msg, status };
|
||||
}
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new MatchaService();
|
||||
36
backend/utils/__tests__/clientPatcher.test.js
Normal file
36
backend/utils/__tests__/clientPatcher.test.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const assert = require('assert');
|
||||
const clientPatcher = require('../clientPatcher');
|
||||
|
||||
function bytesAreZero(buffer) {
|
||||
for (const byte of buffer) {
|
||||
if (byte !== 0x00) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const directStrategy = clientPatcher.getDomainStrategy('authws.net');
|
||||
assert.strictEqual(directStrategy.mode, 'direct');
|
||||
assert.strictEqual(directStrategy.mainDomain, 'authws.net');
|
||||
assert.strictEqual(directStrategy.subdomainPrefix, '');
|
||||
|
||||
const splitStrategy = clientPatcher.getDomainStrategy('auth.sanasol.ws');
|
||||
assert.strictEqual(splitStrategy.mode, 'split');
|
||||
assert.strictEqual(splitStrategy.subdomainPrefix, 'auth.s');
|
||||
assert.strictEqual(splitStrategy.mainDomain, 'anasol.ws');
|
||||
|
||||
const oldSubdomain = clientPatcher.stringToLengthPrefixed('https://tools.');
|
||||
const newSubdomain = clientPatcher.stringToLengthPrefixed('https://');
|
||||
const replaceResult = clientPatcher.replaceBytes(oldSubdomain, oldSubdomain, newSubdomain);
|
||||
const padding = replaceResult.buffer.slice(newSubdomain.length, oldSubdomain.length);
|
||||
assert.ok(bytesAreZero(padding), 'Expected null padding after shorter replacement');
|
||||
|
||||
const oldDomainUtf16 = clientPatcher.stringToUtf16LE('hytale.com');
|
||||
const smartResult = clientPatcher.findAndReplaceDomainSmart(oldDomainUtf16, 'hytale.com', 'auth.ws');
|
||||
const newDomainUtf16 = clientPatcher.stringToUtf16LE('auth.ws');
|
||||
assert.ok(smartResult.buffer.slice(0, newDomainUtf16.length).equals(newDomainUtf16));
|
||||
const smartPadding = smartResult.buffer.slice(newDomainUtf16.length, oldDomainUtf16.length);
|
||||
assert.ok(bytesAreZero(smartPadding), 'Expected null padding in smart replacement');
|
||||
|
||||
console.log('clientPatcher tests passed');
|
||||
@@ -132,9 +132,10 @@ class ClientPatcher {
|
||||
/**
|
||||
* Replace bytes in buffer - only overwrites the length of new bytes
|
||||
*/
|
||||
replaceBytes(buffer, oldBytes, newBytes) {
|
||||
replaceBytes(buffer, oldBytes, newBytes, options = {}) {
|
||||
let count = 0;
|
||||
const result = Buffer.from(buffer);
|
||||
const padNulls = options.padNulls !== false;
|
||||
|
||||
if (newBytes.length > oldBytes.length) {
|
||||
console.warn(` Warning: New pattern (${newBytes.length}) longer than old (${oldBytes.length}), skipping`);
|
||||
@@ -144,6 +145,9 @@ class ClientPatcher {
|
||||
const positions = this.findAllOccurrences(result, oldBytes);
|
||||
for (const pos of positions) {
|
||||
newBytes.copy(result, pos);
|
||||
if (padNulls && newBytes.length < oldBytes.length) {
|
||||
result.fill(0x00, pos + newBytes.length, pos + oldBytes.length);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
@@ -173,14 +177,21 @@ class ClientPatcher {
|
||||
|
||||
if (lastCharFirstByte === oldLastCharByte) {
|
||||
newUtf16NoLast.copy(result, pos);
|
||||
result[lastCharPos] = newLastCharByte;
|
||||
const newLastCharPos = pos + newUtf16NoLast.length;
|
||||
const secondByte = lastCharPos + 1 < result.length ? result[lastCharPos + 1] : 0x00;
|
||||
const isUtf16 = secondByte === 0x00;
|
||||
if (newUtf16NoLast.length < oldUtf16NoLast.length) {
|
||||
const padEnd = isUtf16 ? lastCharPos + 2 : pos + oldUtf16NoLast.length;
|
||||
result.fill(0x00, newLastCharPos + 1, padEnd);
|
||||
}
|
||||
result[newLastCharPos] = newLastCharByte;
|
||||
|
||||
if (lastCharPos + 1 < result.length) {
|
||||
const secondByte = result[lastCharPos + 1];
|
||||
if (secondByte === 0x00) {
|
||||
if (newLastCharPos + 1 < result.length) {
|
||||
const nextByte = result[newLastCharPos + 1];
|
||||
if (nextByte === 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)})`);
|
||||
console.log(` Patched length-prefixed occurrence at offset 0x${pos.toString(16)} (metadata: 0x${nextByte.toString(16)})`);
|
||||
}
|
||||
}
|
||||
count++;
|
||||
@@ -190,6 +201,51 @@ class ClientPatcher {
|
||||
return { buffer: result, count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all domain patches using legacy UTF-16LE format
|
||||
*/
|
||||
applyLegacyPatches(data, domain, protocol = 'https://') {
|
||||
let result = Buffer.from(data);
|
||||
let totalCount = 0;
|
||||
const strategy = this.getDomainStrategy(domain);
|
||||
|
||||
console.log(` Legacy strategy: ${strategy.description}`);
|
||||
|
||||
const oldSentry = 'https://ca900df42fcf57d4dd8401a86ddd7da2@sentry.hytale.com/2';
|
||||
const newSentry = `${protocol}t@${domain}/2`;
|
||||
const sentryResult = this.replaceBytes(
|
||||
result,
|
||||
this.stringToUtf16LE(oldSentry),
|
||||
this.stringToUtf16LE(newSentry)
|
||||
);
|
||||
result = sentryResult.buffer;
|
||||
if (sentryResult.count > 0) {
|
||||
console.log(` Replaced ${sentryResult.count} legacy sentry occurrence(s)`);
|
||||
totalCount += sentryResult.count;
|
||||
}
|
||||
|
||||
const domainResult = this.findAndReplaceDomainSmart(result, ORIGINAL_DOMAIN, strategy.mainDomain);
|
||||
result = domainResult.buffer;
|
||||
if (domainResult.count > 0) {
|
||||
console.log(` Replaced ${domainResult.count} legacy domain occurrence(s)`);
|
||||
totalCount += domainResult.count;
|
||||
}
|
||||
|
||||
const subdomains = ['https://tools.', 'https://sessions.', 'https://account-data.', 'https://telemetry.'];
|
||||
const newSubdomainPrefix = protocol + strategy.subdomainPrefix;
|
||||
|
||||
for (const sub of subdomains) {
|
||||
const subResult = this.findAndReplaceDomainSmart(result, sub, newSubdomainPrefix);
|
||||
result = subResult.buffer;
|
||||
if (subResult.count > 0) {
|
||||
console.log(` Replaced ${subResult.count} legacy subdomain occurrence(s)`);
|
||||
totalCount += subResult.count;
|
||||
}
|
||||
}
|
||||
|
||||
return { buffer: result, count: totalCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all domain patches using length-prefixed format
|
||||
*/
|
||||
@@ -472,7 +528,7 @@ class ClientPatcher {
|
||||
if (count === 0 && discordCount === 0) {
|
||||
console.log('No occurrences found - trying legacy UTF-16LE format...');
|
||||
|
||||
const legacyResult = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, strategy.mainDomain);
|
||||
const legacyResult = this.applyLegacyPatches(data, newDomain);
|
||||
if (legacyResult.count > 0) {
|
||||
console.log(`Found ${legacyResult.count} occurrences with legacy format`);
|
||||
fs.writeFileSync(clientPath, legacyResult.buffer);
|
||||
@@ -645,23 +701,64 @@ class ClientPatcher {
|
||||
* Find client binary path based on platform
|
||||
*/
|
||||
findClientPath(gameDir) {
|
||||
const candidates = this.findClientBinaries(gameDir);
|
||||
return candidates.length > 0 ? candidates[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all client binaries that may contain patchable strings
|
||||
*/
|
||||
findClientBinaries(gameDir) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const clientDir = path.join(gameDir, 'Client');
|
||||
|
||||
const addCandidate = (candidate) => {
|
||||
if (!candidate || seen.has(candidate)) {
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(candidate)) {
|
||||
candidates.push(candidate);
|
||||
seen.add(candidate);
|
||||
}
|
||||
};
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
candidates.push(path.join(gameDir, 'Client', 'Hytale.app', 'Contents', 'MacOS', 'HytaleClient'));
|
||||
candidates.push(path.join(gameDir, 'Client', 'HytaleClient'));
|
||||
const appBundle = path.join(clientDir, 'Hytale.app');
|
||||
const appMacosDir = path.join(appBundle, 'Contents', 'MacOS');
|
||||
|
||||
addCandidate(path.join(appMacosDir, 'HytaleClient'));
|
||||
addCandidate(path.join(clientDir, 'HytaleClient'));
|
||||
|
||||
if (fs.existsSync(appMacosDir)) {
|
||||
const entries = fs.readdirSync(appMacosDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile()) {
|
||||
addCandidate(path.join(appMacosDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
candidates.push(path.join(gameDir, 'Client', 'HytaleClient.exe'));
|
||||
addCandidate(path.join(clientDir, 'HytaleClient.exe'));
|
||||
addCandidate(path.join(clientDir, 'HytaleClient.dll'));
|
||||
|
||||
if (fs.existsSync(clientDir)) {
|
||||
const entries = fs.readdirSync(clientDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const lowerName = entry.name.toLowerCase();
|
||||
if (lowerName.endsWith('.dll')) {
|
||||
addCandidate(path.join(clientDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
candidates.push(path.join(gameDir, 'Client', 'HytaleClient'));
|
||||
addCandidate(path.join(clientDir, 'HytaleClient'));
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -691,17 +788,34 @@ class ClientPatcher {
|
||||
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);
|
||||
}
|
||||
});
|
||||
const clientPaths = this.findClientBinaries(gameDir);
|
||||
if (clientPaths.length > 0) {
|
||||
if (progressCallback) progressCallback('Patching client binaries...', 10);
|
||||
const clientResults = [];
|
||||
let totalPatchCount = 0;
|
||||
|
||||
for (const clientPath of clientPaths) {
|
||||
const result = await this.patchClient(clientPath, (msg, pct) => {
|
||||
if (progressCallback) {
|
||||
progressCallback(`Client: ${path.basename(clientPath)}: ${msg}`, pct ? pct / 2 : null);
|
||||
}
|
||||
});
|
||||
clientResults.push({ path: clientPath, ...result });
|
||||
totalPatchCount += result.patchCount || 0;
|
||||
}
|
||||
|
||||
const allSuccess = clientResults.every((entry) => entry.success);
|
||||
const allAlreadyPatched = clientResults.every((entry) => entry.alreadyPatched);
|
||||
|
||||
results.client = {
|
||||
success: allSuccess,
|
||||
patchCount: totalPatchCount,
|
||||
alreadyPatched: allAlreadyPatched,
|
||||
binaries: clientResults
|
||||
};
|
||||
} else {
|
||||
console.warn('Could not find HytaleClient binary');
|
||||
results.client = { success: false, error: 'Client binary not found' };
|
||||
console.warn('Could not find HytaleClient binaries');
|
||||
results.client = { success: false, error: 'Client binaries not found' };
|
||||
}
|
||||
|
||||
// Download DualAuth ByteBuddy Agent (runtime patching, no JAR modification)
|
||||
|
||||
@@ -594,6 +594,10 @@ function setupGpuEnvironment(gpuPreference) {
|
||||
if (detected.vendor === 'nvidia') {
|
||||
envVars.__NV_PRIME_RENDER_OFFLOAD = '1';
|
||||
envVars.__GLX_VENDOR_LIBRARY_NAME = 'nvidia';
|
||||
// Prevent Wayland explicit sync crashes on NVIDIA (Hyprland, etc.)
|
||||
if (isWaylandSession()) {
|
||||
envVars.__NV_DISABLE_EXPLICIT_SYNC = '1';
|
||||
}
|
||||
const nvidiaEglFile = '/usr/share/glvnd/egl_vendor.d/10_nvidia.json';
|
||||
if (fs.existsSync(nvidiaEglFile)) {
|
||||
envVars.__EGL_VENDOR_LIBRARY_FILENAMES = nvidiaEglFile;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Singleplayer Server Crash: fastutil ClassNotFoundException
|
||||
|
||||
## Status: Open (multiple users, Feb 24-27 2026)
|
||||
## Status: Open — likely outdated HytaleServer.jar (Feb 24-28 2026)
|
||||
|
||||
## Symptom
|
||||
|
||||
Singleplayer server crashes immediately after DualAuth Agent installs successfully:
|
||||
Singleplayer server crashes immediately on boot:
|
||||
|
||||
```
|
||||
Exception in thread "main" java.lang.NoClassDefFoundError: it/unimi/dsi/fastutil/objects/ObjectArrayList
|
||||
@@ -20,104 +20,71 @@ Server exits with code 1. Multiplayer works fine for the same user.
|
||||
|
||||
1. **ヅ𝚃 JAYED !** (Feb 24) — Windows x86_64, had AOT cache errors before fastutil crash
|
||||
2. **Asentrix** (Feb 27) — Windows x86_64 (NT 10.0.26200.0), RTX 4060, Launcher v2.4.4, NO AOT cache errors
|
||||
3. **7645754** (Feb 28) — Standalone server on localhost, **FIXED by updating HytaleServer.jar**
|
||||
|
||||
- Reproduces 100% on singleplayer, every attempt
|
||||
- Multiplayer works fine for both users
|
||||
- Reproduces 100% on singleplayer, every attempt (users 1-2)
|
||||
- Multiplayer works fine for users 1-2
|
||||
- macOS/Linux users are NOT affected
|
||||
|
||||
## What Works
|
||||
## Ruled Out (confirmed via debug builds)
|
||||
|
||||
- Java wrapper correctly strips `-XX:+UseCompactObjectHeaders`
|
||||
- Java wrapper correctly injects `--disable-sentry`
|
||||
- DualAuth Agent v1.1.12 installs successfully (STATIC mode)
|
||||
- Multiplayer connections work fine
|
||||
- Repair and reinstall did NOT fix the issue
|
||||
| Suspect | Tested | Result |
|
||||
|---------|--------|--------|
|
||||
| **DualAuth Agent** | Debug build with agent completely disabled (`debug-no-agent` tag) | **Same crash.** Agent is innocent. |
|
||||
| **`-Xshare:off` (CDS)** | Added to `JAVA_TOOL_OPTIONS` in launcher code (`debug-xshare-off` tag) | **Did not help.** CDS is not the cause. |
|
||||
| **`-XX:+UseCompactObjectHeaders`** | Stripped via wrapper | **Did not help.** Server has `-XX:+IgnoreUnrecognizedVMOptions` anyway. |
|
||||
| **Corrupted game files** | User did repair + full reinstall | **Same crash.** |
|
||||
| **Java wrapper** | Logs confirm wrapper works correctly | Not the cause. |
|
||||
| **ARM64/Parallels** | User is on standard Windows x86_64 | Not applicable. |
|
||||
| **AOT cache** | Asentrix has no AOT errors (JAYED did), both crash the same way | Not the root cause. |
|
||||
|
||||
## Root Cause Analysis
|
||||
## Key Finding: Outdated HytaleServer.jar (Feb 28)
|
||||
|
||||
`fastutil` (`it.unimi.dsi.fastutil`) should be bundled inside `HytaleServer.jar` (fat JAR). The `ClassNotFoundException` means the JVM's app classloader cannot find it despite it being in the JAR.
|
||||
User `7645754` had the **exact same error** on their standalone localhost server but NOT on their VPS. **Fixed by replacing `HytaleServer.jar` with the current version.** The old JAR used to work but stopped — likely the bundled JRE was updated and is now incompatible with older JAR versions.
|
||||
|
||||
### Ruled Out
|
||||
This strongly suggests the root cause for F2P launcher users is also a **stale/mismatched `HytaleServer.jar`**. The launcher may report the correct version but the actual file on disk could be from an older download.
|
||||
|
||||
- **Wrapper issue**: Wrapper is working correctly (confirmed in logs)
|
||||
- **UseCompactObjectHeaders**: Server also has `-XX:+IgnoreUnrecognizedVMOptions`, so unrecognized flags don't crash it
|
||||
- **DualAuth Agent**: Works for all other users; agent installs successfully before the crash
|
||||
- **Corrupted game files**: Repair/reinstall didn't help
|
||||
- **ARM64/Parallels**: User is on standard Windows, not ARM
|
||||
## What We Know
|
||||
|
||||
### Likely Cause
|
||||
- `fastutil` is bundled inside `HytaleServer.jar` (fat/shaded JAR)
|
||||
- JVM's `BuiltinClassLoader` cannot find `it.unimi.dsi.fastutil.objects.ObjectArrayList` despite it being in the JAR
|
||||
- Crash happens at `EarlyPluginLoader` static initializer (line 34) which imports `ObjectArrayList`
|
||||
- **Replacing `HytaleServer.jar` with a fresh copy fixes the issue** (confirmed by user 3)
|
||||
- The issue is NOT caused by the DualAuth agent or any launcher modification
|
||||
|
||||
**CDS (Class Data Sharing) broken by bootstrap classloader modification.** DualAuth agent calls `appendToBootstrapClassLoaderSearch()` which triggers JVM warning: `"Sharing is only supported for boot loader classes because bootstrap classpath has been appended"`. This disables AppCDS for application classes. On some Windows systems, this breaks the classloader's ability to find classes (including fastutil) from the fat JAR.
|
||||
## Fix for Users
|
||||
|
||||
This warning appears for ALL users, but only breaks classloading on some Windows systems — reason unknown.
|
||||
### F2P Launcher users (Asentrix, JAYED)
|
||||
1. **Delete the entire game folder**: `%LOCALAPPDATA%\HytaleF2P\release\package\game\`
|
||||
2. Relaunch — launcher will re-download everything fresh
|
||||
3. NOT just "repair" — full delete to ensure no stale files remain
|
||||
|
||||
### Other Possible Causes
|
||||
|
||||
1. **Antivirus interference** — AV blocking Java from reading classes out of JAR files
|
||||
2. **File locking** — another process holding HytaleServer.jar open (Asentrix had stalled java.exe killed at launch)
|
||||
|
||||
## Potential Fix: `-Xshare:off` (testing Feb 27)
|
||||
|
||||
Disables CDS entirely, forcing standard classloading. User can add via launcher:
|
||||
1. **Settings** → **Java Wrapper Configuration** → **Arguments to Inject**
|
||||
2. Add `-Xshare:off` with **Server Only** condition
|
||||
3. Retry singleplayer
|
||||
|
||||
Sent to affected users for testing — **awaiting results**.
|
||||
|
||||
If confirmed, should be added as default inject arg (server-only) in launcher config.
|
||||
|
||||
## Debugging Steps (for reference)
|
||||
|
||||
Most steps are impractical for F2P users:
|
||||
- ~~Official Hytale singleplayer~~ — F2P users don't have official access
|
||||
- ~~Try without DualAuth agent~~ — not possible, agent required for F2P token validation
|
||||
- ~~Verify fastutil in JAR~~ — same JAR for all users, not a user-actionable step
|
||||
- ~~Check JRE version~~ — bundled with launcher, same for all users
|
||||
|
||||
**Practical steps:**
|
||||
1. **Add `-Xshare:off`** via wrapper inject args (server-only) — testing now
|
||||
2. **Check antivirus** — add game directory to Windows Defender exclusions
|
||||
3. **Check for stalled processes** — kill any leftover java.exe/HytaleServer before launch
|
||||
### Standalone server users
|
||||
1. Download fresh `HytaleServer.jar` from current game version
|
||||
2. Replace the old JAR file
|
||||
|
||||
## Update History
|
||||
|
||||
### Feb 24: `-XX:+UseCompactObjectHeaders` stripping removed from defaults
|
||||
Stripping this flag did NOT fix the issue. The server already has `-XX:+IgnoreUnrecognizedVMOptions` so unrecognized flags are harmless. The flag was removed from default `stripFlags` in `backend/core/config.js`.
|
||||
### Feb 24: First report (JAYED)
|
||||
User reported singleplayer crash. Initial investigation found AOT cache errors + fastutil ClassNotFoundException. Stripping `-XX:+UseCompactObjectHeaders` did not help.
|
||||
|
||||
### Feb 27: Second user (Asentrix) reported, `-Xshare:off` sent for testing
|
||||
Asentrix hit the same crash on Launcher v2.4.4. Unlike JAYED, no AOT cache errors — just the CDS sharing warning followed by fastutil ClassNotFoundException. This confirms the issue is not AOT-specific but related to CDS/classloader interaction with the DualAuth agent's bootstrap CL modification. Sent `-Xshare:off` workaround to affected users — awaiting results.
|
||||
### Feb 27: Second report (Asentrix), extensive debugging
|
||||
- Asentrix hit same crash, no AOT errors — ruled out AOT as root cause
|
||||
- Built `debug-xshare-off`: added `-Xshare:off` to `JAVA_TOOL_OPTIONS` — **did not help**
|
||||
- Built `debug-no-agent`: completely disabled DualAuth agent — **same crash**
|
||||
- **Conclusion**: Neither the agent nor CDS is the cause. The JVM itself cannot load classes from the fat JAR on these specific Windows systems.
|
||||
- Note: wrapper `injectArgs` append AFTER `-jar`, so they cannot inject JVM flags — only `JAVA_TOOL_OPTIONS` works for JVM flags
|
||||
|
||||
## Using the Java Wrapper to Strip JVM Flags
|
||||
|
||||
If a user needs to strip a specific JVM flag (e.g., for debugging or compatibility), they can do it via the launcher UI:
|
||||
|
||||
1. Open **Settings** → scroll to **Java Wrapper Configuration**
|
||||
2. Under **JVM Flags to Remove**, type the flag (e.g. `-XX:+UseCompactObjectHeaders`) and click **Add**
|
||||
3. The flag will be stripped from all JVM invocations at launch time
|
||||
4. To inject custom arguments, use the **Arguments to Inject** section (with optional "Server Only" condition)
|
||||
5. **Restore Defaults** resets to empty strip flags + `--disable-sentry` (server only)
|
||||
|
||||
The wrapper generates platform-specific scripts at launch time:
|
||||
- **Windows**: `java-wrapper.bat` in `jre/latest/bin/`
|
||||
- **macOS/Linux**: `java-wrapper` shell script in the same directory
|
||||
|
||||
Config is stored in `config.json` under `javaWrapperConfig`:
|
||||
```json
|
||||
{
|
||||
"javaWrapperConfig": {
|
||||
"stripFlags": ["-XX:+SomeFlag"],
|
||||
"injectArgs": [
|
||||
{ "arg": "--some-arg", "condition": "server" },
|
||||
{ "arg": "--other-arg", "condition": "always" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
### Feb 28: Third user (7645754) — FIXED by replacing HytaleServer.jar
|
||||
- Standalone server user had same crash on localhost, VPS worked fine
|
||||
- **Fixed by updating `HytaleServer.jar` to match VPS version**
|
||||
- Root cause likely: outdated JAR incompatible with current/updated JRE
|
||||
- For F2P launcher users: need to delete game folder and force fresh re-download
|
||||
|
||||
## Related
|
||||
|
||||
- Java wrapper config: `backend/core/config.js` (stripFlags / injectArgs)
|
||||
- DualAuth Agent: v1.1.12, package `ws.sanasol.dualauth`
|
||||
- Game version at time of reports: `2026.02.19-1a311a592`
|
||||
- Log submission ID (Asentrix): `c88e7b71`
|
||||
- Debug tags: `debug-xshare-off`, `debug-no-agent`
|
||||
- Log submission IDs: `c88e7b71` (Asentrix initial), `0445e4dc` (xshare test), `748dceeb` (no-agent test)
|
||||
|
||||
370
main.js
370
main.js
@@ -6,6 +6,7 @@ const fs = require('fs');
|
||||
const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, saveDiscordRPC, loadDiscordRPC, saveLanguage, loadLanguage, saveCloseLauncherOnStart, loadCloseLauncherOnStart, saveLauncherHardwareAcceleration, loadLauncherHardwareAcceleration, saveAllowMultiInstance, loadAllowMultiInstance, isGameInstalled, uninstallGame, repairGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched, loadConfig, saveConfig, checkLaunchReady } = require('./backend/launcher');
|
||||
const { retryPWRDownload } = require('./backend/managers/gameManager');
|
||||
const { migrateUserDataToCentralized } = require('./backend/utils/userDataMigration');
|
||||
const matchaService = require('./backend/services/matchaService');
|
||||
|
||||
// Handle Hardware Acceleration
|
||||
try {
|
||||
@@ -193,7 +194,7 @@ function createWindow() {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
devTools: false,
|
||||
devTools: process.argv.includes('--dev'),
|
||||
webSecurity: true
|
||||
}
|
||||
});
|
||||
@@ -213,6 +214,9 @@ function createWindow() {
|
||||
// Initialize Discord Rich Presence
|
||||
initDiscordRPC();
|
||||
|
||||
// Initialize Matcha Social service
|
||||
matchaService.init(mainWindow);
|
||||
|
||||
// Configure and initialize electron-updater
|
||||
// Enable auto-download so updates start immediately when available
|
||||
autoUpdater.autoDownload = true;
|
||||
@@ -251,7 +255,7 @@ function createWindow() {
|
||||
mainWindow.webContents.send('update-error', {
|
||||
message: err.message,
|
||||
isMacSigningError: isMacSigningError,
|
||||
requiresManualDownload: isMacSigningError || process.platform === 'darwin'
|
||||
requiresManualDownload: isMacSigningError
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -272,9 +276,7 @@ function createWindow() {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('update-downloaded', {
|
||||
version: info.version,
|
||||
platform: process.platform,
|
||||
// macOS auto-install often fails on unsigned apps
|
||||
autoInstallSupported: process.platform !== 'darwin'
|
||||
platform: process.platform
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -286,9 +288,12 @@ function createWindow() {
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
mainWindow.webContents.on('devtools-opened', () => {
|
||||
mainWindow.webContents.closeDevTools();
|
||||
});
|
||||
const isDev = process.argv.includes('--dev');
|
||||
if (!isDev) {
|
||||
mainWindow.webContents.on('devtools-opened', () => {
|
||||
mainWindow.webContents.closeDevTools();
|
||||
});
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
// Allow standard copy/paste/cut/select-all shortcuts
|
||||
@@ -301,18 +306,20 @@ function createWindow() {
|
||||
return; // Don't block these
|
||||
}
|
||||
|
||||
// Block devtools shortcuts
|
||||
if (input.control && input.shift && input.key.toLowerCase() === 'i') {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (input.control && input.shift && input.key.toLowerCase() === 'j') {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (input.control && input.shift && input.key.toLowerCase() === 'c') {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (input.key === 'F12') {
|
||||
event.preventDefault();
|
||||
// Block devtools shortcuts (except in dev mode)
|
||||
if (!isDev) {
|
||||
if (input.control && input.shift && input.key.toLowerCase() === 'i') {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (input.control && input.shift && input.key.toLowerCase() === 'j') {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (input.control && input.shift && input.key.toLowerCase() === 'c') {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (input.key === 'F12') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
if (input.key === 'F5') {
|
||||
event.preventDefault();
|
||||
@@ -506,6 +513,7 @@ async function cleanupDiscordRPC() {
|
||||
app.on('before-quit', () => {
|
||||
console.log('=== LAUNCHER BEFORE QUIT ===');
|
||||
cleanupDiscordRPC();
|
||||
matchaService.destroy();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
@@ -530,9 +538,34 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g
|
||||
}
|
||||
};
|
||||
|
||||
const result = await launchGameWithVersionCheck(playerName, progressCallback, javaPath, installPath, gpuPreference);
|
||||
// Check if UUID has password before launching
|
||||
let launchOptions = {};
|
||||
const { getAuthServerUrl, getUuidForUser } = require('./backend/core/config');
|
||||
const launchUuid = getUuidForUser(playerName);
|
||||
try {
|
||||
const uuid = launchUuid;
|
||||
const authServerUrl = getAuthServerUrl();
|
||||
const statusResp = await fetch(`${authServerUrl}/player/password/status/${uuid}`);
|
||||
if (statusResp.ok) {
|
||||
const status = await statusResp.json();
|
||||
if (status.hasPassword) {
|
||||
// Return to renderer to prompt for password
|
||||
return { success: false, passwordRequired: true, uuid };
|
||||
}
|
||||
}
|
||||
} catch (pwErr) {
|
||||
console.log('[Launch] Password check skipped:', pwErr.message);
|
||||
}
|
||||
|
||||
const result = await launchGameWithVersionCheck(playerName, progressCallback, javaPath, installPath, gpuPreference, null, launchOptions);
|
||||
|
||||
if (result.success && result.launched) {
|
||||
// Save last played timestamp
|
||||
try { saveConfig({ last_played: Date.now() }); } catch (e) { /* ignore */ }
|
||||
|
||||
// Notify Matcha that game is running (heartbeat will send 'in_game')
|
||||
matchaService.setGameRunning(true);
|
||||
|
||||
const closeOnStart = loadCloseLauncherOnStart();
|
||||
if (closeOnStart) {
|
||||
console.log('Close Launcher on start enabled, quitting application...');
|
||||
@@ -554,10 +587,72 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
if (error.passwordRequired) {
|
||||
return { success: false, passwordRequired: true, uuid: launchUuid, error: 'Password required' };
|
||||
}
|
||||
if (error.lockedOut) {
|
||||
return { success: false, error: 'Too many failed attempts. Try again in ' + Math.ceil((error.lockoutSeconds || 900) / 60) + ' minutes.' };
|
||||
}
|
||||
if (error.usernameTaken) {
|
||||
return { success: false, usernameTaken: true, error: errorMessage };
|
||||
}
|
||||
if (error.nameLocked) {
|
||||
return { success: false, nameLocked: true, registeredName: error.registeredName, error: error.message };
|
||||
}
|
||||
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('launch-game-with-password', async (event, playerName, javaPath, installPath, gpuPreference, password) => {
|
||||
try {
|
||||
const progressCallback = (message, percent, speed, downloaded, total, retryState) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('progress-update', {
|
||||
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,
|
||||
retryState: retryState || null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const result = await launchGameWithVersionCheck(playerName, progressCallback, javaPath, installPath, gpuPreference, null, { password });
|
||||
|
||||
if (result.success && result.launched) {
|
||||
try { saveConfig({ last_played: Date.now() }); } catch (e) { /* ignore */ }
|
||||
|
||||
matchaService.setGameRunning(true);
|
||||
|
||||
const closeOnStart = loadCloseLauncherOnStart();
|
||||
if (closeOnStart) {
|
||||
setTimeout(() => { app.quit(); }, 1000);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Launch with password error:', error);
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
setTimeout(() => { mainWindow.webContents.send('progress-complete'); }, 2000);
|
||||
}
|
||||
if (error.passwordRequired) {
|
||||
return { success: false, passwordRequired: true, error: 'Incorrect password. ' + (error.attemptsRemaining != null ? error.attemptsRemaining + ' attempts remaining.' : '') };
|
||||
}
|
||||
if (error.lockedOut) {
|
||||
return { success: false, error: 'Too many failed attempts. Try again in ' + Math.ceil((error.lockoutSeconds || 900) / 60) + ' minutes.' };
|
||||
}
|
||||
if (error.usernameTaken) {
|
||||
return { success: false, usernameTaken: true, error: error.message || error.toString() };
|
||||
}
|
||||
if (error.nameLocked) {
|
||||
return { success: false, nameLocked: true, registeredName: error.registeredName, error: error.message };
|
||||
}
|
||||
return { success: false, error: error.message || error.toString() };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('install-game', async (event, playerName, javaPath, installPath, branch) => {
|
||||
try {
|
||||
console.log(`[IPC] install-game called with parameters:`);
|
||||
@@ -1289,6 +1384,11 @@ ipcMain.handle('get-detected-gpu', () => {
|
||||
ipcMain.handle('save-version-branch', (event, branch) => {
|
||||
const { saveVersionBranch } = require('./backend/launcher');
|
||||
saveVersionBranch(branch);
|
||||
// Sync to active profile
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
if (activeProfile) {
|
||||
profileManager.updateProfile(activeProfile.id, { versionBranch: branch });
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -1302,6 +1402,43 @@ ipcMain.handle('load-version-client', () => {
|
||||
return loadVersionClient();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-game-info', async () => {
|
||||
const { loadVersionClient, loadVersionBranch } = require('./backend/launcher');
|
||||
const { fetchMirrorManifest } = require('./backend/services/versionManager');
|
||||
const config = loadConfig();
|
||||
const branch = loadVersionBranch();
|
||||
|
||||
let version = null;
|
||||
let readableVersion = null;
|
||||
try {
|
||||
const manifest = await fetchMirrorManifest();
|
||||
if (manifest?.versions?.[branch]) {
|
||||
const branchVersions = manifest.versions[branch];
|
||||
// Get the highest version number for the current branch
|
||||
const nums = Object.keys(branchVersions).map(Number).filter(n => !isNaN(n));
|
||||
if (nums.length > 0) {
|
||||
const latest = Math.max(...nums).toString();
|
||||
version = `v${latest}`;
|
||||
readableVersion = branchVersions[latest]?.version || null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Manifest fetch failed, fall back to stored version
|
||||
version = loadVersionClient();
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
version = loadVersionClient();
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
readableVersion,
|
||||
branch,
|
||||
lastPlayed: config.last_played || null
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('window-close', () => {
|
||||
app.quit();
|
||||
});
|
||||
@@ -1352,9 +1489,12 @@ ipcMain.handle('get-all-uuid-mappings', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('set-uuid-for-user', async (event, username, uuid) => {
|
||||
ipcMain.handle('set-uuid-for-user', async (event, username, uuid, force) => {
|
||||
try {
|
||||
await setUuidForUser(username, uuid);
|
||||
const result = setUuidForUser(username, uuid, { force: !!force });
|
||||
if (result && result.success === false) {
|
||||
return result; // { success: false, error: 'duplicate', existingUuid }
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error setting UUID for user:', error);
|
||||
@@ -1391,6 +1531,88 @@ ipcMain.handle('reset-current-user-uuid', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Password Management IPC handlers
|
||||
ipcMain.handle('check-password-status', async (event, uuid) => {
|
||||
try {
|
||||
const { getAuthServerUrl } = require('./backend/core/config');
|
||||
const authServerUrl = getAuthServerUrl();
|
||||
const response = await fetch(`${authServerUrl}/player/password/status/${uuid}`);
|
||||
if (!response.ok) return { hasPassword: false };
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error checking password status:', error);
|
||||
return { hasPassword: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('set-player-password', async (event, uuid, password, currentPassword) => {
|
||||
try {
|
||||
const { getAuthServerUrl } = require('./backend/core/config');
|
||||
const { getUuidForUser, loadUsername } = require('./backend/core/config');
|
||||
const authServerUrl = getAuthServerUrl();
|
||||
// First get a bearer token for auth
|
||||
const name = loadUsername() || 'Player';
|
||||
const tokenResp = await fetch(`${authServerUrl}/game-session/child`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uuid, name, password: currentPassword || undefined })
|
||||
});
|
||||
if (!tokenResp.ok) {
|
||||
const err = await tokenResp.json().catch(() => ({}));
|
||||
return { success: false, error: err.error || 'Failed to authenticate' };
|
||||
}
|
||||
const tokenData = await tokenResp.json();
|
||||
const bearerToken = tokenData.identityToken || tokenData.IdentityToken;
|
||||
|
||||
const response = await fetch(`${authServerUrl}/player/password/set`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${bearerToken}`
|
||||
},
|
||||
body: JSON.stringify({ uuid, password, currentPassword: currentPassword || undefined })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error setting password:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('remove-player-password', async (event, uuid, currentPassword) => {
|
||||
try {
|
||||
const { getAuthServerUrl } = require('./backend/core/config');
|
||||
const { loadUsername } = require('./backend/core/config');
|
||||
const authServerUrl = getAuthServerUrl();
|
||||
const name = loadUsername() || 'Player';
|
||||
// Get bearer token with current password
|
||||
const tokenResp = await fetch(`${authServerUrl}/game-session/child`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uuid, name, password: currentPassword })
|
||||
});
|
||||
if (!tokenResp.ok) {
|
||||
const err = await tokenResp.json().catch(() => ({}));
|
||||
return { success: false, error: err.error || 'Failed to authenticate' };
|
||||
}
|
||||
const tokenData = await tokenResp.json();
|
||||
const bearerToken = tokenData.identityToken || tokenData.IdentityToken;
|
||||
|
||||
const response = await fetch(`${authServerUrl}/player/password/remove`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${bearerToken}`
|
||||
},
|
||||
body: JSON.stringify({ uuid, currentPassword })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error removing password:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-recent-logs', async (event, maxLines = 100) => {
|
||||
try {
|
||||
const logDir = logger.getLogDirectory();
|
||||
@@ -1597,6 +1819,108 @@ ipcMain.handle('preview-wrapper-script', (event, config, platform) => {
|
||||
return generateWrapperScript(config || require('./backend/launcher').loadWrapperConfig(), platform || process.platform, '/path/to/java');
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// MATCHA SOCIAL IPC HANDLERS
|
||||
// =============================================================================
|
||||
|
||||
ipcMain.handle('matcha:log', (event, level, ...args) => {
|
||||
const prefix = '[Matcha/Renderer]';
|
||||
if (level === 'error') console.error(prefix, ...args);
|
||||
else if (level === 'warn') console.warn(prefix, ...args);
|
||||
else console.log(prefix, ...args);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:register', async (event, username, password, password2) => {
|
||||
return matchaService.register(username, password, password2);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:confirm-register', async (event, pendingId, proofId) => {
|
||||
return matchaService.confirmRegistration(pendingId, proofId);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:login', async (event, handle, password) => {
|
||||
return matchaService.login(handle, password);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:logout', async () => {
|
||||
return matchaService.logout();
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:get-auth-state', () => {
|
||||
return matchaService.getAuthState();
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:get-me', async () => {
|
||||
return matchaService.getMe();
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:get-user', async (event, userId) => {
|
||||
return matchaService.getUser(userId);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:get-friends', async () => {
|
||||
return matchaService.getFriends();
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:friend-request', async (event, handle) => {
|
||||
return matchaService.sendFriendRequest(handle);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:friend-accept', async (event, requestId) => {
|
||||
return matchaService.acceptFriend(requestId);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:friend-reject', async (event, requestId) => {
|
||||
return matchaService.rejectFriend(requestId);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:friend-cancel', async (event, requestId) => {
|
||||
return matchaService.cancelFriendRequest(requestId);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:friend-remove', async (event, friendId) => {
|
||||
return matchaService.removeFriend(friendId);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:get-messages', async (event, withTarget, cursor, after) => {
|
||||
return matchaService.getMessages(withTarget, cursor, after);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:send-message', async (event, to, body, replyTo) => {
|
||||
return matchaService.sendMessage(to, body, replyTo);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:delete-message', async (event, messageId) => {
|
||||
return matchaService.deleteMessage(messageId);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:get-unread', async () => {
|
||||
return matchaService.getUnread();
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:clear-unread', async (event, withTarget) => {
|
||||
return matchaService.clearUnread(withTarget);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:upload-avatar', async (event, mode) => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Select Avatar Image',
|
||||
filters: [{ name: 'PNG Images', extensions: ['png'] }],
|
||||
properties: ['openFile']
|
||||
});
|
||||
if (result.canceled || !result.filePaths[0]) return { ok: false, error: 'Cancelled' };
|
||||
return matchaService.uploadAvatar(result.filePaths[0], mode);
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:delete-avatar', async () => {
|
||||
return matchaService.deleteAvatar();
|
||||
});
|
||||
|
||||
ipcMain.handle('matcha:reconnect', () => {
|
||||
matchaService.manualReconnect();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('get-current-platform', () => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
54
package-lock.json
generated
54
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hytale-f2p-launcher",
|
||||
"version": "2.4.2",
|
||||
"version": "2.4.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hytale-f2p-launcher",
|
||||
"version": "2.4.2",
|
||||
"version": "2.4.8",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.10",
|
||||
@@ -17,7 +17,8 @@
|
||||
"encoding": "^0.1.13",
|
||||
"fs-extra": "^11.3.3",
|
||||
"tar": "^7.5.7",
|
||||
"uuid": "^9.0.1"
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^40.0.0",
|
||||
@@ -335,6 +336,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -789,7 +791,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -1592,7 +1593,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
@@ -1803,6 +1805,27 @@
|
||||
"register-scheme": "github:devsnek/node-register-scheme"
|
||||
}
|
||||
},
|
||||
"node_modules/discord-rpc/node_modules/ws": {
|
||||
"version": "7.5.10",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": "^5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/dmg-builder": {
|
||||
"version": "26.6.0",
|
||||
"resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.6.0.tgz",
|
||||
@@ -2094,6 +2117,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -2114,6 +2138,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -2129,6 +2154,7 @@
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
@@ -2139,6 +2165,7 @@
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
@@ -2155,7 +2182,6 @@
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
@@ -3399,6 +3425,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -3751,7 +3778,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3781,6 +3807,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -3798,6 +3825,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -4006,6 +4034,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -4403,6 +4432,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -4712,16 +4742,16 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "7.5.10",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.3.0"
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": "^5.0.2"
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "hytale-f2p-launcher",
|
||||
"version": "2.4.5",
|
||||
"version": "2.4.8",
|
||||
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
|
||||
"homepage": "https://git.sanhost.net/sanasol/hytale-f2p",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --dev",
|
||||
"test": "node backend/utils/__tests__/clientPatcher.test.js",
|
||||
"build": "electron-builder",
|
||||
"build:win": "electron-builder --win",
|
||||
"build:linux": "electron-builder --linux",
|
||||
@@ -56,7 +57,8 @@
|
||||
"electron-updater": "^6.7.3",
|
||||
"fs-extra": "^11.3.3",
|
||||
"tar": "^7.5.7",
|
||||
"uuid": "^9.0.1"
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.hytalef2p.launcher",
|
||||
|
||||
47
preload.js
47
preload.js
@@ -2,6 +2,7 @@ const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
launchGame: (playerName, javaPath, installPath, gpuPreference) => ipcRenderer.invoke('launch-game', playerName, javaPath, installPath, gpuPreference),
|
||||
launchGameWithPassword: (playerName, javaPath, installPath, gpuPreference, password) => ipcRenderer.invoke('launch-game-with-password', playerName, javaPath, installPath, gpuPreference, password),
|
||||
installGame: (playerName, javaPath, installPath, branch) => ipcRenderer.invoke('install-game', playerName, javaPath, installPath, branch),
|
||||
closeWindow: () => ipcRenderer.invoke('window-close'),
|
||||
minimizeWindow: () => ipcRenderer.invoke('window-minimize'),
|
||||
@@ -78,6 +79,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
saveVersionBranch: (branch) => ipcRenderer.invoke('save-version-branch', branch),
|
||||
loadVersionBranch: () => ipcRenderer.invoke('load-version-branch'),
|
||||
loadVersionClient: () => ipcRenderer.invoke('load-version-client'),
|
||||
getGameInfo: () => ipcRenderer.invoke('get-game-info'),
|
||||
|
||||
acceptFirstLaunchUpdate: (existingGame) => ipcRenderer.invoke('accept-first-launch-update', existingGame),
|
||||
markAsLaunched: () => ipcRenderer.invoke('mark-as-launched'),
|
||||
@@ -102,11 +104,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 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),
|
||||
setUuidForUser: (username, uuid, force) => ipcRenderer.invoke('set-uuid-for-user', username, uuid, force),
|
||||
generateNewUuid: () => ipcRenderer.invoke('generate-new-uuid'),
|
||||
deleteUuidForUser: (username) => ipcRenderer.invoke('delete-uuid-for-user', username),
|
||||
resetCurrentUserUuid: () => ipcRenderer.invoke('reset-current-user-uuid'),
|
||||
|
||||
// Password Management methods
|
||||
checkPasswordStatus: (uuid) => ipcRenderer.invoke('check-password-status', uuid),
|
||||
setPlayerPassword: (uuid, password, currentPassword) => ipcRenderer.invoke('set-player-password', uuid, password, currentPassword),
|
||||
removePlayerPassword: (uuid, currentPassword) => ipcRenderer.invoke('remove-player-password', uuid, currentPassword),
|
||||
promptPassword: () => ipcRenderer.invoke('prompt-password'),
|
||||
onPasswordPrompt: (callback) => {
|
||||
ipcRenderer.on('show-password-prompt', (event, data) => callback(data));
|
||||
},
|
||||
|
||||
// Java Wrapper Config API
|
||||
loadWrapperConfig: () => ipcRenderer.invoke('load-wrapper-config'),
|
||||
saveWrapperConfig: (config) => ipcRenderer.invoke('save-wrapper-config', config),
|
||||
@@ -142,5 +153,39 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
},
|
||||
onUpdateError: (callback) => {
|
||||
ipcRenderer.on('update-error', (event, data) => callback(data));
|
||||
},
|
||||
|
||||
// Matcha Social API
|
||||
matcha: {
|
||||
log: (level, ...args) => ipcRenderer.invoke('matcha:log', level, ...args),
|
||||
register: (username, password, password2) => ipcRenderer.invoke('matcha:register', username, password, password2),
|
||||
confirmRegister: (pendingId, proofId) => ipcRenderer.invoke('matcha:confirm-register', pendingId, proofId),
|
||||
login: (handle, password) => ipcRenderer.invoke('matcha:login', handle, password),
|
||||
logout: () => ipcRenderer.invoke('matcha:logout'),
|
||||
getAuthState: () => ipcRenderer.invoke('matcha:get-auth-state'),
|
||||
getMe: () => ipcRenderer.invoke('matcha:get-me'),
|
||||
getUser: (userId) => ipcRenderer.invoke('matcha:get-user', userId),
|
||||
getFriends: () => ipcRenderer.invoke('matcha:get-friends'),
|
||||
friendRequest: (handle) => ipcRenderer.invoke('matcha:friend-request', handle),
|
||||
friendAccept: (requestId) => ipcRenderer.invoke('matcha:friend-accept', requestId),
|
||||
friendReject: (requestId) => ipcRenderer.invoke('matcha:friend-reject', requestId),
|
||||
friendCancel: (requestId) => ipcRenderer.invoke('matcha:friend-cancel', requestId),
|
||||
friendRemove: (friendId) => ipcRenderer.invoke('matcha:friend-remove', friendId),
|
||||
getMessages: (withTarget, cursor, after) => ipcRenderer.invoke('matcha:get-messages', withTarget, cursor, after),
|
||||
sendMessage: (to, body, replyTo) => ipcRenderer.invoke('matcha:send-message', to, body, replyTo),
|
||||
deleteMessage: (messageId) => ipcRenderer.invoke('matcha:delete-message', messageId),
|
||||
getUnread: () => ipcRenderer.invoke('matcha:get-unread'),
|
||||
clearUnread: (withTarget) => ipcRenderer.invoke('matcha:clear-unread', withTarget),
|
||||
uploadAvatar: (mode) => ipcRenderer.invoke('matcha:upload-avatar', mode),
|
||||
deleteAvatar: () => ipcRenderer.invoke('matcha:delete-avatar'),
|
||||
reconnect: () => ipcRenderer.invoke('matcha:reconnect'),
|
||||
onWsMessage: (callback) => ipcRenderer.on('matcha:ws:message', (event, data) => callback(data)),
|
||||
onWsConnected: (callback) => ipcRenderer.on('matcha:ws:connected', (event, data) => callback(data)),
|
||||
onWsDisconnected: (callback) => ipcRenderer.on('matcha:ws:disconnected', () => callback()),
|
||||
onMessageDeleted: (callback) => ipcRenderer.on('matcha:ws:message-deleted', (event, data) => callback(data)),
|
||||
onAvatarUpdated: (callback) => ipcRenderer.on('matcha:ws:avatar-updated', (event, data) => callback(data)),
|
||||
onBanned: (callback) => ipcRenderer.on('matcha:ws:banned', (event, data) => callback(data)),
|
||||
onAnnouncement: (callback) => ipcRenderer.on('matcha:ws:announcement', (event, data) => callback(data)),
|
||||
onMaxRetries: (callback) => ipcRenderer.on('matcha:ws:max-retries', () => callback())
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
> **Deprecated:** For the new F2P Evo launcher, see [F2P Evo Server Scripts](https://git.sanhost.net/sanasol/f2p-evo/src/branch/main/server/).
|
||||
|
||||
# Hytale F2P - Dedicated Server
|
||||
|
||||
Host your own Hytale server. The scripts handle everything automatically.
|
||||
@@ -130,5 +132,4 @@ your-folder/
|
||||
|
||||
## Community
|
||||
|
||||
Need help? Join the community: TG Group: https://t.me/sanhostnet | TG Channel: https://t.me/hf2p_og | Chat: https://chat.sanhost.net/invite/Tfz4jCK4
|
||||
<!-- Discord: https://discord.gg/Fhbb9Yk5WW -->
|
||||
Need help? Join the community: [Community Chat](https://chat.sanhost.net/invite/Tfz4jCK4) | [TG Group](https://t.me/sanhostnet) | [TG Channel](https://t.me/hf2p_og)
|
||||
|
||||
Reference in New Issue
Block a user