Compare commits

...

11 Commits

Author SHA1 Message Date
sanasol
a2b60ba3a0 docs: deprecate Hytale-F2P in favor of F2P Evo
Replace README with short deprecation notice pointing to f2p-evo.
Add deprecation headers to SERVER.md, TROUBLESHOOTING.md, server/README.md.
Remove CloudNord advertising, buymeacoffee links, and Discord references.
Update Docker URLs from Hybrowse to sanasol, fix contact links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:23:57 +01:00
sanasol
0e50ded8e4 MAESTRO: harden patcher replacements for multi-binary installs 2026-03-05 18:43:33 +01:00
sanasol
c5e5ddbcc4 fix: presence field name (f.state not f.presence) and WS message routing via data.convo
Bug 1: Butter API returns friend presence in `state` field, not `presence`.
All 7 occurrences (friends list sort, count, display, DM header) now use f.state.

Bug 2: WS messages use `data.convo` for conversation routing ("global" or convo ID).
Message object may not have toId/to fields, causing all messages to fall into DM branch.
Now checks data.convo first with msg field fallbacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:06:13 +01:00
sanasol
e610072fa6 fix: sync package-lock.json with ws dependency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 01:01:08 +01:00
sanasol
a40465cac9 feat: Matcha! social integration — friends, chat, DMs, avatars, presence
Add full Matcha social panel (butter.lat API) as a right-side slide-out:

Backend (matchaService.js):
- HTTP client for auth, friends, messages, unread, avatar, heartbeat APIs
- WebSocket with auto-reconnect (exponential backoff, no hard cap)
- Token management via config, presence heartbeat every 30s
- WS error message type handling, game running presence

Renderer (matcha.js):
- State machine UI: intro → login/register → app (friends/chat/DMs/profile)
- Two-phase registration with master key display and verification
- Friends list with presence dots, collapsible requests, 12s polling
- Global chat + DM with optimistic rendering, cursor pagination
- Scroll position preserved on load-more, separate loading flags
- Clickable URLs in messages (linkify with proper escaping)
- User profile popup with avatar upload/delete
- Unread badges (messages + friend requests) on nav icon
- Escape key closes panel/overlay, try/catch on auth flows

IPC bridge (preload.js + main.js):
- 21 IPC invoke methods + 8 WS event listeners
- Avatar upload via file picker dialog in main process
- Game launch sets in_game heartbeat state

CSS (style.css):
- ~1500 lines: panel, auth screens, friends, chat, profile, toast
- Responsive panel width, improved contrast, no overflow clipping
- Loading states, disabled states, pulse animations

Credits: Powered by Butter Launcher & Matcha! (butterlauncher.tech)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 00:49:37 +01:00
sanasol
57056e5b7a v2.4.8: UI improvements, update popup fixes, per-profile branch tracking
- Fix auto-update popup: indeterminate progress fallback when no download events, show 100% on complete
- Remove macOS auto-update warning (app is now signed)
- Disable update popup pulse animation
- Remove news tab and news section from home screen
- Center play section vertically, add community links with colored icons
- Add game version + branch display on play page (from manifest)
- Add last played timestamp tracking
- Version badge links to git.sanhost.net releases
- Profiles now store version_branch and version_client per-configuration
- Profile switch restores branch/version and refreshes settings UI
- DevTools enabled in dev mode (electron . --dev)
- Reorder community links: Chat, Discord, TG Channel, TG Group, Source

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:47:23 +01:00
sanasol
fcf041be39 v2.4.7: Fix Linux SDL3_image crash and NVIDIA Wayland stability
- Add LD_LIBRARY_PATH with Client directory so the dynamic linker finds
  bundled native libraries (libSDL3_image.so.0, etc). Official Hytale
  uses Flatpak which provides these; F2P runs natively and needs this.
- Add __NV_DISABLE_EXPLICIT_SYNC=1 for NVIDIA on Wayland to prevent
  crashes on Hyprland and other Wayland compositors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:11:42 +01:00
sanasol
d53ac915f3 Update fastutil classloader issue docs: outdated HytaleServer.jar identified as root cause, add fix steps for users 2026-02-28 18:30:33 +01:00
sanasol
7347910fe9 feat: identity protection UI, duplicate guards, name-lock enforcement (v2.4.6)
- Add password set/change/remove with loading states and double-click prevention
- Add protected identity deletion flow (server-side password removal first)
- Add restore flow for password-protected UUIDs (verify password before saving)
- Add UUID duplicate checks in setUuidForUser (prevent accidental overwrites)
- Add name-locked error handling in launch flow (server enforces registered name)
- Sync shield icon across all identity mutation paths
- Refresh identity dropdown after all password/identity operations
- Propagate force flag through IPC for legitimate overwrites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:22:40 +01:00
sanasol
0b861904ba feat: add password protection UI and fix launch flow
- Password management UI in settings (set/change/remove password)
- Shield icon on play button for protected identities
- Interactive password popup on launch with inline error display
- Fix: re-throw password errors instead of falling to local tokens
- Fix: password popup properly cleans up on success/cancel
- Fix: expose updatePasswordShieldIcon for cross-module access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:45:46 +01:00
sanasol
ee53911a06 Revert debug builds, update fastutil issue docs
Agent and -Xshare:off both ruled out as causes.
Restored normal agent injection. Updated docs with
complete findings — issue remains unsolved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:15:01 +01:00
26 changed files with 6240 additions and 898 deletions

View File

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

View File

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

View File

@@ -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> &amp;
<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>

View File

@@ -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, "&#39;")}')">
<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

File diff suppressed because it is too large Load Diff

View File

@@ -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();
});
}
})();

View File

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

View File

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

View File

@@ -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';
}

File diff suppressed because it is too large Load Diff

505
README.md
View File

@@ -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
[![GitHub Downloads](https://img.shields.io/github/downloads/amiayweb/Hytale-F2P/total?style=for-the-badge)](https://github.com/amiayweb/Hytale-F2P/releases)
[![Version](https://img.shields.io/badge/Version-2.2.1-red?style=for-the-badge)](https://github.com/amiayweb/Hytale-F2P/releases)
[![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-teal?style=for-the-badge)](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
[![GitHub stars](https://img.shields.io/github/stars/amiayweb/Hytale-F2P?style=for-the-badge&logo=github)](https://github.com/amiayweb/Hytale-F2P/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/amiayweb/Hytale-F2P?style=for-the-badge&logo=github)](https://github.com/amiayweb/Hytale-F2P/network/members)
[![GitHub issues](https://img.shields.io/github/issues/amiayweb/Hytale-F2P?style=for-the-badge&logo=github)](https://github.com/amiayweb/Hytale-F2P/issues)
![License](https://img.shields.io/badge/License-Educational-purple?style=for-the-badge)
## 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!**
[![Star History Chart](https://api.star-history.com/svg?repos=amiayweb/Hytale-F2P&type=date&legend=top-left)](https://www.star-history.com/#amiayweb/Hytale-F2P&type=date&legend=top-left)
</div>
---
## 📸 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**
[![Contributors](https://contrib.rocks/image?repo=amiayweb/Hytale-F2P)](https://github.com/amiayweb/Hytale-F2P/graphs/contributors)
</div>
### 🏆 Project Creator
- [**@amiayweb**](https://github.com/amiayweb) - *Lead Developer & Project Creator*
- [**@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*
[![Star History Chart](https://api.star-history.com/svg?repos=amiayweb/Hytale-F2P&type=date&legend=top-left)](https://www.star-history.com/#amiayweb/Hytale-F2P&type=date&legend=top-left)
</div>
- 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.

View File

@@ -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, weve 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!** Dont 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)

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

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

@@ -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": {

View File

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

View File

@@ -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())
}
});

View File

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