mirror of
https://git.sanhost.net/sanasol/hytale-f2p
synced 2026-04-21 21:02:26 -04:00
Compare commits
4 Commits
0b861904ba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57056e5b7a | ||
|
|
fcf041be39 | ||
|
|
d53ac915f3 | ||
|
|
7347910fe9 |
@@ -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>
|
||||
@@ -219,36 +215,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">
|
||||
@@ -301,15 +299,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">
|
||||
@@ -950,34 +939,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="version-display-bottom">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -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,15 +292,11 @@ 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();
|
||||
}
|
||||
resetPlayButton();
|
||||
|
||||
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 {
|
||||
@@ -302,9 +305,57 @@ export async function launch() {
|
||||
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) {
|
||||
// UUID has a password — show interactive password dialog
|
||||
const launchResult = await promptForPasswordAndLaunch(playerName, javaPath, gpuPreference);
|
||||
// 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);
|
||||
}
|
||||
@@ -312,12 +363,21 @@ export async function launch() {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -373,7 +433,7 @@ export async function launch() {
|
||||
}
|
||||
}
|
||||
|
||||
function promptForPasswordAndLaunch(playerName, javaPath, gpuPreference) {
|
||||
function promptForPasswordAndLaunch(playerName, javaPath, gpuPreference, uuid) {
|
||||
return new Promise((resolve) => {
|
||||
// Remove any existing password prompt
|
||||
const existing = document.querySelector('.custom-confirm-modal');
|
||||
@@ -427,6 +487,10 @@ function promptForPasswordAndLaunch(playerName, javaPath, gpuPreference) {
|
||||
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="
|
||||
@@ -458,6 +522,7 @@ function promptForPasswordAndLaunch(playerName, javaPath, gpuPreference) {
|
||||
const confirmBtn = overlay.querySelector('#pwConfirmBtn');
|
||||
const cancelBtn = overlay.querySelector('#pwCancelBtn');
|
||||
const errorMsg = overlay.querySelector('#pwErrorMsg');
|
||||
const rememberCheck = overlay.querySelector('#pwRememberCheck');
|
||||
|
||||
let busy = false;
|
||||
|
||||
@@ -494,15 +559,20 @@ function promptForPasswordAndLaunch(playerName, javaPath, gpuPreference) {
|
||||
|
||||
try {
|
||||
if (window.LauncherUI) window.LauncherUI.showProgress();
|
||||
isDownloading = true;
|
||||
const playBtn = document.getElementById('play-btn');
|
||||
const playText = playBtn?.querySelector('.play-text');
|
||||
if (playBtn) { playBtn.disabled = true; }
|
||||
if (playText) { playText.textContent = 'LAUNCHING...'; }
|
||||
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();
|
||||
@@ -779,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...';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import './ui.js';
|
||||
import './install.js';
|
||||
import './launcher.js';
|
||||
import './news.js';
|
||||
import { initModsManager } from './mods.js';
|
||||
import './players.js';
|
||||
import './settings.js';
|
||||
|
||||
@@ -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';
|
||||
@@ -981,10 +1194,12 @@ async function refreshPasswordStatus() {
|
||||
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);
|
||||
@@ -994,20 +1209,25 @@ window.handleSetPassword = async function () {
|
||||
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);
|
||||
@@ -1016,11 +1236,14 @@ window.handleRemovePassword = async function () {
|
||||
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'; }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1108,10 +1331,12 @@ window.closePasswordModal = function () {
|
||||
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);
|
||||
@@ -1121,21 +1346,26 @@ window.handlePasswordModalSet = async function () {
|
||||
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);
|
||||
@@ -1143,12 +1373,15 @@ window.handlePasswordModalRemove = async function () {
|
||||
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'; }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1240,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;
|
||||
@@ -1251,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');
|
||||
}
|
||||
@@ -1329,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)
|
||||
@@ -1348,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1540,6 +1924,11 @@ async function switchBranch(newBranch) {
|
||||
await loadVersionBranch();
|
||||
console.log('[Settings] Radio buttons updated after branch switch');
|
||||
|
||||
// Refresh game info bar on play page
|
||||
if (window.LauncherUI?.loadGameInfoBar) {
|
||||
window.LauncherUI.loadGameInfoBar();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (window.LauncherUI) {
|
||||
window.LauncherUI.hideProgress();
|
||||
|
||||
59
GUI/js/ui.js
59
GUI/js/ui.js
@@ -138,6 +138,7 @@ function showLauncherOrInstall(isInstalled) {
|
||||
if (gameTitle) gameTitle.style.display = '';
|
||||
showPage('play-page');
|
||||
setActiveNav('play');
|
||||
loadGameInfoBar();
|
||||
} else {
|
||||
if (launcher) launcher.style.display = 'none';
|
||||
if (install) {
|
||||
@@ -738,13 +739,69 @@ async function checkGameInstallation() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGameInfoBar() {
|
||||
try {
|
||||
const info = await window.electronAPI.getGameInfo();
|
||||
const bar = document.getElementById('game-info-bar');
|
||||
if (!bar) return;
|
||||
|
||||
const versionEl = document.getElementById('game-version-info');
|
||||
const branchEl = document.getElementById('game-branch-info');
|
||||
const lastPlayedEl = document.getElementById('game-last-played');
|
||||
|
||||
if (versionEl) {
|
||||
versionEl.classList.remove('game-info-loading');
|
||||
if (info.version) {
|
||||
const display = info.readableVersion
|
||||
? `${info.version} - ${info.readableVersion}`
|
||||
: info.version;
|
||||
versionEl.textContent = display;
|
||||
} else {
|
||||
versionEl.textContent = 'Not installed';
|
||||
}
|
||||
}
|
||||
if (branchEl) {
|
||||
branchEl.classList.remove('game-info-loading');
|
||||
const isPreRelease = info.branch === 'pre-release';
|
||||
branchEl.textContent = isPreRelease ? 'Pre-Release' : 'Release';
|
||||
branchEl.style.color = isPreRelease ? '#f59e0b' : '';
|
||||
}
|
||||
|
||||
if (lastPlayedEl && info.lastPlayed) {
|
||||
lastPlayedEl.style.display = '';
|
||||
if (!lastPlayedEl.previousElementSibling?.classList?.contains('game-info-sep')) {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'game-info-sep';
|
||||
lastPlayedEl.before(sep);
|
||||
}
|
||||
lastPlayedEl.textContent = formatTimeAgo(info.lastPlayed);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not load game info:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp) {
|
||||
const diff = Date.now() - timestamp;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return 'Just played';
|
||||
if (minutes < 60) return `Played ${minutes}m ago`;
|
||||
if (hours < 24) return `Played ${hours}h ago`;
|
||||
if (days < 30) return `Played ${days}d ago`;
|
||||
return `Played ${Math.floor(days / 30)}mo ago`;
|
||||
}
|
||||
|
||||
window.LauncherUI = {
|
||||
showPage,
|
||||
setActiveNav,
|
||||
showLauncherOrInstall,
|
||||
showProgress,
|
||||
hideProgress,
|
||||
updateProgress
|
||||
updateProgress,
|
||||
loadGameInfoBar
|
||||
};
|
||||
|
||||
// Make installation effects globally available
|
||||
|
||||
@@ -16,6 +16,8 @@ class ClientUpdateManager {
|
||||
});
|
||||
|
||||
window.electronAPI.onUpdateDownloadProgress((progress) => {
|
||||
console.log('📊 download-progress event:', progress);
|
||||
this.downloadProgressReceived = true;
|
||||
this.updateDownloadProgress(progress);
|
||||
});
|
||||
|
||||
@@ -49,7 +51,7 @@ class ClientUpdateManager {
|
||||
|
||||
const popupHTML = `
|
||||
<div id="update-popup-overlay">
|
||||
<div class="update-popup-container update-popup-pulse">
|
||||
<div class="update-popup-container">
|
||||
<div class="update-popup-header">
|
||||
<div class="update-popup-icon">
|
||||
<i class="fas fa-download"></i>
|
||||
@@ -118,11 +120,29 @@ class ClientUpdateManager {
|
||||
this.blockInterface();
|
||||
|
||||
// Show progress container immediately (auto-download is enabled)
|
||||
this.downloadProgressReceived = false;
|
||||
const progressContainer = document.getElementById('update-progress-container');
|
||||
if (progressContainer) {
|
||||
progressContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
// If no progress events arrive within 2 seconds, show indeterminate animation
|
||||
setTimeout(() => {
|
||||
if (!this.downloadProgressReceived && progressContainer) {
|
||||
const bar = document.getElementById('update-progress-bar');
|
||||
const speed = document.getElementById('update-progress-speed');
|
||||
const percent = document.getElementById('update-progress-percent');
|
||||
if (bar) {
|
||||
bar.style.width = '100%';
|
||||
bar.style.animation = 'indeterminateProgress 1.5s ease-in-out infinite';
|
||||
bar.style.opacity = '0.7';
|
||||
}
|
||||
if (speed) speed.textContent = '';
|
||||
if (percent) percent.textContent = 'Downloading...';
|
||||
console.log('⏳ No download-progress events received, showing indeterminate progress');
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
const installBtn = document.getElementById('update-install-btn');
|
||||
if (installBtn) {
|
||||
installBtn.addEventListener('click', async (e) => {
|
||||
@@ -248,6 +268,10 @@ class ClientUpdateManager {
|
||||
const progressSize = document.getElementById('update-progress-size');
|
||||
|
||||
if (progressBar && progress) {
|
||||
// Stop indeterminate animation if it was running
|
||||
progressBar.style.animation = 'none';
|
||||
progressBar.style.opacity = '1';
|
||||
|
||||
const percent = Math.round(progress.percent || 0);
|
||||
progressBar.style.width = `${percent}%`;
|
||||
|
||||
@@ -273,57 +297,35 @@ class ClientUpdateManager {
|
||||
showUpdateDownloaded(updateInfo) {
|
||||
const statusText = document.getElementById('update-status-text');
|
||||
const progressContainer = document.getElementById('update-progress-container');
|
||||
const progressBar = document.getElementById('update-progress-bar');
|
||||
const progressPercent = document.getElementById('update-progress-percent');
|
||||
const progressSpeed = document.getElementById('update-progress-speed');
|
||||
const progressSize = document.getElementById('update-progress-size');
|
||||
const buttonsContainer = document.getElementById('update-buttons-container');
|
||||
const installBtn = document.getElementById('update-install-btn');
|
||||
const downloadBtn = document.getElementById('update-download-btn');
|
||||
const skipBtn = document.getElementById('update-skip-btn');
|
||||
const footerText = document.getElementById('update-footer-text');
|
||||
const popupContainer = document.querySelector('.update-popup-container');
|
||||
|
||||
// Remove breathing/pulse animation when download is complete
|
||||
if (popupContainer) {
|
||||
popupContainer.classList.remove('update-popup-pulse');
|
||||
// Stop indeterminate animation and show 100%
|
||||
if (progressBar) {
|
||||
progressBar.style.animation = 'none';
|
||||
progressBar.style.opacity = '1';
|
||||
progressBar.style.width = '100%';
|
||||
}
|
||||
if (progressPercent) progressPercent.textContent = '100%';
|
||||
if (progressSpeed) progressSpeed.textContent = 'Complete';
|
||||
if (progressContainer) progressContainer.style.display = 'block';
|
||||
|
||||
// Hide progress after a short delay so user sees 100%
|
||||
setTimeout(() => {
|
||||
if (progressContainer) progressContainer.style.display = 'none';
|
||||
}, 1500);
|
||||
|
||||
if (statusText) {
|
||||
statusText.textContent = 'Update downloaded! Ready to install.';
|
||||
}
|
||||
|
||||
if (progressContainer) {
|
||||
progressContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Use platform info from main process if available, fallback to browser detection
|
||||
const autoInstallSupported = updateInfo.autoInstallSupported !== undefined
|
||||
? updateInfo.autoInstallSupported
|
||||
: navigator.platform.toUpperCase().indexOf('MAC') < 0;
|
||||
|
||||
if (!autoInstallSupported) {
|
||||
// macOS: Show manual download as primary since auto-update doesn't work
|
||||
if (statusText) {
|
||||
statusText.textContent = 'Update downloaded but auto-install may not work on macOS.';
|
||||
}
|
||||
|
||||
if (installBtn) {
|
||||
// Still show install button but as secondary option
|
||||
installBtn.classList.add('update-download-btn-secondary');
|
||||
installBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Try Install & Restart';
|
||||
}
|
||||
|
||||
if (downloadBtn) {
|
||||
// Make manual download primary
|
||||
downloadBtn.classList.remove('update-download-btn-secondary');
|
||||
downloadBtn.innerHTML = '<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>Download Manually (Recommended)';
|
||||
}
|
||||
|
||||
if (footerText) {
|
||||
footerText.textContent = 'Auto-install often fails on macOS:';
|
||||
}
|
||||
} else {
|
||||
// Windows/Linux: Auto-install should work
|
||||
if (statusText) {
|
||||
statusText.textContent = 'Update downloaded! Ready to install.';
|
||||
}
|
||||
|
||||
if (footerText) {
|
||||
footerText.textContent = 'Click to install the update:';
|
||||
}
|
||||
if (footerText) {
|
||||
footerText.textContent = 'Click to install the update:';
|
||||
}
|
||||
|
||||
if (buttonsContainer) {
|
||||
@@ -338,7 +340,7 @@ class ClientUpdateManager {
|
||||
console.error('❌ Skip button not found in DOM!');
|
||||
}
|
||||
|
||||
console.log('✅ Update downloaded, ready to install. autoInstallSupported:', autoInstallSupported);
|
||||
console.log('✅ Update downloaded, ready to install');
|
||||
}
|
||||
|
||||
handleUpdateError(errorInfo) {
|
||||
@@ -366,9 +368,6 @@ class ClientUpdateManager {
|
||||
|
||||
if (errorMessage && errorText) {
|
||||
let message = errorInfo.message || 'An error occurred during the update process.';
|
||||
if (errorInfo.isMacSigningError) {
|
||||
message = 'Auto-update requires code signing. Please download manually.';
|
||||
}
|
||||
errorText.textContent = message;
|
||||
errorMessage.style.display = 'block';
|
||||
}
|
||||
|
||||
@@ -646,12 +646,18 @@ body {
|
||||
}
|
||||
|
||||
|
||||
#play-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.play-section {
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.play-content {
|
||||
@@ -663,6 +669,62 @@ body {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.game-info-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.game-info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.game-info-loading {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.game-info-sep {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.community-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.community-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
color: #9ca3af;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.community-link:hover {
|
||||
color: #e2e8f0;
|
||||
border-color: rgba(147, 51, 234, 0.4);
|
||||
background: rgba(147, 51, 234, 0.1);
|
||||
}
|
||||
|
||||
.play-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
@@ -5260,19 +5322,15 @@ select.settings-input option {
|
||||
}
|
||||
|
||||
|
||||
.update-popup-pulse {
|
||||
animation: updatePulse 2s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
@keyframes updatePulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
@keyframes indeterminateProgress {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -82,6 +82,12 @@ async function fetchAuthTokens(uuid, name, password) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -123,7 +129,7 @@ async function fetchAuthTokens(uuid, name, password) {
|
||||
return { identityToken, sessionToken };
|
||||
} catch (error) {
|
||||
// Re-throw authentication errors — must not fall back to local tokens
|
||||
if (error.passwordRequired || error.lockedOut || error.usernameTaken) {
|
||||
if (error.passwordRequired || error.lockedOut || error.usernameTaken || error.nameLocked) {
|
||||
throw error;
|
||||
}
|
||||
console.error('Failed to fetch auth tokens:', error.message);
|
||||
@@ -439,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);
|
||||
@@ -694,7 +711,7 @@ async function launchGameWithVersionCheck(playerNameOverride = null, progressCal
|
||||
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) {
|
||||
if (error.passwordRequired || error.lockedOut || error.usernameTaken || error.nameLocked) {
|
||||
throw error;
|
||||
}
|
||||
// Always return an error response instead of throwing
|
||||
|
||||
@@ -4,7 +4,11 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const {
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
getModsPath
|
||||
getModsPath,
|
||||
loadVersionBranch,
|
||||
saveVersionBranch,
|
||||
loadVersionClient,
|
||||
saveVersionClient
|
||||
} = require('../core/config');
|
||||
|
||||
// Lazy-load modManager to avoid circular deps, or keep imports structured.
|
||||
@@ -44,6 +48,8 @@ class ProfileManager {
|
||||
// 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,6 +79,7 @@ class ProfileManager {
|
||||
const config = loadConfig();
|
||||
const id = uuidv4();
|
||||
|
||||
// New profiles inherit the current branch/version
|
||||
const newProfile = {
|
||||
id,
|
||||
name: name.trim(),
|
||||
@@ -80,6 +87,8 @@ class ProfileManager {
|
||||
lastUsed: null,
|
||||
mods: [], // Start with no mods enabled
|
||||
javaPath: '',
|
||||
versionBranch: loadVersionBranch(),
|
||||
versionClient: loadVersionClient(),
|
||||
gameOptions: {
|
||||
minMemory: '1G',
|
||||
maxMemory: '4G',
|
||||
@@ -129,19 +138,34 @@ 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({
|
||||
activeProfileId: id,
|
||||
profiles: config.profiles
|
||||
});
|
||||
|
||||
// 2. Trigger Mod Sync
|
||||
// We need to require this here to ensure it uses the *newly saved* active profile ID
|
||||
// 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 => {
|
||||
|
||||
@@ -594,6 +594,10 @@ function setupGpuEnvironment(gpuPreference) {
|
||||
if (detected.vendor === 'nvidia') {
|
||||
envVars.__NV_PRIME_RENDER_OFFLOAD = '1';
|
||||
envVars.__GLX_VENDOR_LIBRARY_NAME = 'nvidia';
|
||||
// Prevent Wayland explicit sync crashes on NVIDIA (Hyprland, etc.)
|
||||
if (isWaylandSession()) {
|
||||
envVars.__NV_DISABLE_EXPLICIT_SYNC = '1';
|
||||
}
|
||||
const nvidiaEglFile = '/usr/share/glvnd/egl_vendor.d/10_nvidia.json';
|
||||
if (fs.existsSync(nvidiaEglFile)) {
|
||||
envVars.__EGL_VENDOR_LIBRARY_FILENAMES = nvidiaEglFile;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Singleplayer Server Crash: fastutil ClassNotFoundException
|
||||
|
||||
## Status: Open — NO SOLUTION (Feb 24-27 2026)
|
||||
## Status: Open — likely outdated HytaleServer.jar (Feb 24-28 2026)
|
||||
|
||||
## Symptom
|
||||
|
||||
@@ -20,9 +20,10 @@ 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
|
||||
|
||||
## Ruled Out (confirmed via debug builds)
|
||||
@@ -37,26 +38,30 @@ Server exits with code 1. Multiplayer works fine for the same user.
|
||||
| **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. |
|
||||
|
||||
## Key Finding: Outdated HytaleServer.jar (Feb 28)
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## What We Know
|
||||
|
||||
- `fastutil` is bundled inside `HytaleServer.jar` (fat/shaded JAR) — same JAR for all users
|
||||
- `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`
|
||||
- The bundled JRE is identical for all users (downloaded by launcher)
|
||||
- The issue is **not** caused by anything the F2P launcher adds — it's the vanilla server JVM failing to load its own classes
|
||||
- **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
|
||||
|
||||
## Remaining Theories
|
||||
## Fix for Users
|
||||
|
||||
1. **Antivirus/security software** — Windows Defender or third-party AV intercepting JAR file reads. Real-time scanning + fat JAR = known conflict. **Untested** — user should try disabling AV temporarily.
|
||||
2. **Windows Insider build** — Asentrix is on NT 10.0.26200.0 (Windows 11 Dev/Insider). Bleeding-edge Windows may have JVM compatibility issues.
|
||||
3. **File locking** — Stalled `java.exe` processes holding `HytaleServer.jar` open (Asentrix had stalled processes killed at every launch).
|
||||
4. **Corrupted JRE on disk** — Despite being the same download, filesystem or AV may have corrupted specific JRE files on their system.
|
||||
### 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
|
||||
|
||||
## Next Steps to Try
|
||||
|
||||
1. **Disable Windows Defender** temporarily — the only quick test left
|
||||
2. **Delete bundled JRE** and let launcher re-download — rules out local JRE corruption
|
||||
3. **Ask if official Hytale singleplayer works** — if it also crashes, it's their system (but F2P users may not have access)
|
||||
### Standalone server users
|
||||
1. Download fresh `HytaleServer.jar` from current game version
|
||||
2. Replace the old JAR file
|
||||
|
||||
## Update History
|
||||
|
||||
@@ -70,6 +75,12 @@ User reported singleplayer crash. Initial investigation found AOT cache errors +
|
||||
- **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
|
||||
|
||||
### 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)
|
||||
|
||||
110
main.js
110
main.js
@@ -193,7 +193,7 @@ function createWindow() {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
devTools: false,
|
||||
devTools: process.argv.includes('--dev'),
|
||||
webSecurity: true
|
||||
}
|
||||
});
|
||||
@@ -251,7 +251,7 @@ function createWindow() {
|
||||
mainWindow.webContents.send('update-error', {
|
||||
message: err.message,
|
||||
isMacSigningError: isMacSigningError,
|
||||
requiresManualDownload: isMacSigningError || process.platform === 'darwin'
|
||||
requiresManualDownload: isMacSigningError
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -272,9 +272,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 +284,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 +302,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();
|
||||
@@ -532,10 +535,10 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g
|
||||
|
||||
// Check if UUID has password before launching
|
||||
let launchOptions = {};
|
||||
const { getAuthServerUrl, getUuidForUser } = require('./backend/core/config');
|
||||
const launchUuid = getUuidForUser(playerName);
|
||||
try {
|
||||
const { getAuthServerUrl } = require('./backend/core/config');
|
||||
const { getUuidForUser } = require('./backend/core/config');
|
||||
const uuid = getUuidForUser(playerName);
|
||||
const uuid = launchUuid;
|
||||
const authServerUrl = getAuthServerUrl();
|
||||
const statusResp = await fetch(`${authServerUrl}/player/password/status/${uuid}`);
|
||||
if (statusResp.ok) {
|
||||
@@ -552,6 +555,9 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g
|
||||
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 */ }
|
||||
|
||||
const closeOnStart = loadCloseLauncherOnStart();
|
||||
if (closeOnStart) {
|
||||
console.log('Close Launcher on start enabled, quitting application...');
|
||||
@@ -574,7 +580,7 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g
|
||||
}
|
||||
|
||||
if (error.passwordRequired) {
|
||||
return { success: false, passwordRequired: true, error: 'Password required' };
|
||||
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.' };
|
||||
@@ -582,6 +588,9 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g
|
||||
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 };
|
||||
}
|
||||
@@ -605,6 +614,7 @@ ipcMain.handle('launch-game-with-password', async (event, playerName, javaPath,
|
||||
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 */ }
|
||||
const closeOnStart = loadCloseLauncherOnStart();
|
||||
if (closeOnStart) {
|
||||
setTimeout(() => { app.quit(); }, 1000);
|
||||
@@ -625,6 +635,9 @@ ipcMain.handle('launch-game-with-password', async (event, playerName, javaPath,
|
||||
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() };
|
||||
}
|
||||
});
|
||||
@@ -1360,6 +1373,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 };
|
||||
});
|
||||
|
||||
@@ -1373,6 +1391,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();
|
||||
});
|
||||
@@ -1423,9 +1478,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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -79,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'),
|
||||
@@ -103,7 +104,7 @@ 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'),
|
||||
|
||||
Reference in New Issue
Block a user