Compare commits

...

4 Commits

Author SHA1 Message Date
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
10 changed files with 1405 additions and 157 deletions

View File

@@ -77,6 +77,7 @@
<button class="identity-btn" onclick="toggleIdentityDropdown()"> <button class="identity-btn" onclick="toggleIdentityDropdown()">
<i class="fas fa-id-badge"></i> <i class="fas fa-id-badge"></i>
<span id="currentIdentityName">Player</span> <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> <i class="fas fa-chevron-down"></i>
</button> </button>
<div class="identity-dropdown" id="identityDropdown"> <div class="identity-dropdown" id="identityDropdown">
@@ -830,6 +831,41 @@
</p> </p>
</div> </div>
</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> </div>
</div> </div>
@@ -861,6 +897,59 @@
</div> </div>
</div> </div>
<!-- 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"> <div class="version-display-bottom">
<i class="fas fa-code-branch"></i> <i class="fas fa-code-branch"></i>
<span id="launcherVersion"></span> <span id="launcherVersion"></span>

View File

@@ -193,7 +193,8 @@ window.switchProfile = async (id) => {
}; };
export async function launch() { 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) // STEP 1: Check launch readiness from backend (single source of truth)
@@ -271,11 +272,7 @@ export async function launch() {
// STEP 3: Start launch process // STEP 3: Start launch process
// ========================================================================== // ==========================================================================
if (window.LauncherUI) window.LauncherUI.showProgress(); if (window.LauncherUI) window.LauncherUI.showProgress();
isDownloading = true; lockPlayButton('LAUNCHING...');
if (playBtn) {
playBtn.disabled = true;
playText.textContent = 'LAUNCHING...';
}
try { try {
const startingMsg = window.i18n ? window.i18n.t('progress.startingGame') : 'Starting game...'; const startingMsg = window.i18n ? window.i18n.t('progress.startingGame') : 'Starting game...';
@@ -285,20 +282,92 @@ export async function launch() {
// Pass playerName from config - backend will validate again // Pass playerName from config - backend will validate again
const result = await window.electronAPI.launchGame(playerName, javaPath, '', gpuPreference); const result = await window.electronAPI.launchGame(playerName, javaPath, '', gpuPreference);
isDownloading = false; if (result.usernameTaken) {
// Username reserved by another player
if (window.LauncherUI) { isDownloading = false;
window.LauncherUI.hideProgress(); 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) { 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) { if (window.electronAPI.minimizeWindow) {
setTimeout(() => { setTimeout(() => {
window.electronAPI.minimizeWindow(); window.electronAPI.minimizeWindow();
}, 500); }, 500);
} }
} else { } else {
isDownloading = false;
if (window.LauncherUI) window.LauncherUI.hideProgress();
resetPlayButton();
console.error('[Launcher] Launch failed:', result.error); console.error('[Launcher] Launch failed:', result.error);
// Handle specific error cases // Handle specific error cases
@@ -354,6 +423,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) { function showCustomConfirm(message, title, onConfirm, onCancel = null, confirmText, cancelText) {
// Apply defaults with i18n support // Apply defaults with i18n support
title = title || (window.i18n ? window.i18n.t('confirm.defaultTitle') : 'Confirm Action'); title = title || (window.i18n ? window.i18n.t('confirm.defaultTitle') : 'Confirm Action');
@@ -589,9 +839,21 @@ async function performRepair() {
function resetPlayButton() { function resetPlayButton() {
isDownloading = false; isDownloading = false;
if (playBtn) { const btn = homePlayBtn || playBtn;
playBtn.disabled = false; if (btn) {
playText.textContent = window.i18n ? window.i18n.t('play.play') : 'PLAY'; 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 +974,7 @@ async function loadIdentities() {
} }
} }
function renderIdentityList(mappings, currentUsername) { async function renderIdentityList(mappings, currentUsername) {
const list = document.getElementById('identityList'); const list = document.getElementById('identityList');
if (!list) return; if (!list) return;
@@ -721,13 +983,31 @@ function renderIdentityList(mappings, currentUsername) {
return; 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 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 ` return `
<div class="identity-item ${m.username === currentUsername ? 'active' : ''}" <div class="identity-item ${isActive ? 'active' : ''}"
onclick="switchIdentity('${safe.replace(/'/g, "&#39;")}')"> onclick="switchIdentity('${safe.replace(/'/g, "&#39;")}')">
<span>${safe}</span> <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> </div>
`; `;
}).join(''); }).join('');
@@ -774,6 +1054,9 @@ window.switchIdentity = async (username) => {
if (settingsInput) settingsInput.value = username; if (settingsInput) settingsInput.value = username;
if (window.loadCurrentUuid) window.loadCurrentUuid(); if (window.loadCurrentUuid) window.loadCurrentUuid();
// Update password shield icon for new identity
if (window.updatePasswordShieldIcon) window.updatePasswordShieldIcon();
} catch (error) { } catch (error) {
console.error('Failed to switch identity:', error); console.error('Failed to switch identity:', error);
} }

View File

@@ -515,8 +515,9 @@ async function savePlayerName() {
// Also refresh the UUID list to update which entry is marked as current // Also refresh the UUID list to update which entry is marked as current
await loadAllUuids(); await loadAllUuids();
// Refresh header identity dropdown // Refresh header identity dropdown + shield icon
if (window.loadIdentities) window.loadIdentities(); if (window.loadIdentities) window.loadIdentities();
updatePasswordShieldIcon();
} catch (error) { } catch (error) {
console.error('Error saving player name:', error); console.error('Error saving player name:', error);
@@ -746,6 +747,7 @@ async function performRegenerateUuid() {
await loadAllUuids(); await loadAllUuids();
} }
if (window.loadIdentities) window.loadIdentities(); if (window.loadIdentities) window.loadIdentities();
updatePasswordShieldIcon();
} else { } else {
throw new Error(result.error || 'Failed to generate new UUID'); throw new Error(result.error || 'Failed to generate new UUID');
} }
@@ -912,18 +914,61 @@ async function confirmAddIdentity() {
return; return;
} }
if (window.electronAPI && window.electronAPI.setUuidForUser) { // Check if name already exists locally
const result = await window.electronAPI.setUuidForUser(username, uuid); if (window.electronAPI && window.electronAPI.getAllUuidMappings) {
if (result.success) { const mappings = await window.electronAPI.getAllUuidMappings();
const msg = window.i18n ? window.i18n.t('notifications.identityAdded') : 'Identity added successfully!'; const existing = mappings.find(m => m.username.toLowerCase() === username.toLowerCase());
showNotification(msg, 'success'); if (existing) {
hideAddIdentityForm(); showNotification(`Identity "${existing.username}" already exists (UUID: ${existing.uuid.substring(0, 8)}...). Use the identity list to manage it.`, 'error');
await loadAllUuids(); return;
if (window.loadIdentities) window.loadIdentities(); }
} else { // Check if UUID already used by another identity
throw new Error(result.error || 'Failed to add 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) { } catch (error) {
console.error('Error adding identity:', error); console.error('Error adding identity:', error);
const msg = window.i18n ? window.i18n.t('notifications.identityAddFailed') : 'Failed to add identity'; 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() { function toggleAdvancedSection() {
if (!uuidAdvancedContent || !uuidAdvancedToggle) return; if (!uuidAdvancedContent || !uuidAdvancedToggle) return;
const isOpen = uuidAdvancedContent.style.display !== 'none'; 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) { window.regenerateUuidForUser = async function (username) {
try { 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.'; 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'); showNotification(msg, 'error');
return; 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 (result.success) {
if (currentUuidDisplay) currentUuidDisplay.value = uuid; if (currentUuidDisplay) currentUuidDisplay.value = uuid;
@@ -1027,6 +1484,7 @@ async function performSetCustomUuid(uuid) {
await loadAllUuids(); await loadAllUuids();
if (window.loadIdentities) window.loadIdentities(); if (window.loadIdentities) window.loadIdentities();
updatePasswordShieldIcon();
} else { } else {
throw new Error(result.error || 'Failed to set custom UUID'); 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 // Refresh the UUID list to show new "Current" badge
await loadAllUuids(); await loadAllUuids();
// Refresh header identity dropdown // Refresh header identity dropdown + shield icon
if (window.loadIdentities) window.loadIdentities(); if (window.loadIdentities) window.loadIdentities();
updatePasswordShieldIcon();
const msg = window.i18n const msg = window.i18n
? window.i18n.t('notifications.switchUsernameSuccess').replace('{username}', username) ? window.i18n.t('notifications.switchUsernameSuccess').replace('{username}', username)
@@ -1124,46 +1583,195 @@ async function performSwitchToUsername(username) {
window.deleteUuid = async function (username) { window.deleteUuid = async function (username) {
try { 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.`; // Look up UUID for this username
const title = window.i18n ? window.i18n.t('confirm.deleteUuidTitle') : 'Delete UUID'; let uuid = null;
const confirmBtn = window.i18n ? window.i18n.t('confirm.deleteUuidButton') : 'Delete'; if (window.electronAPI && window.electronAPI.getAllUuidMappings) {
const cancelBtn = window.i18n ? window.i18n.t('common.cancel') : 'Cancel'; const mappings = await window.electronAPI.getAllUuidMappings();
const entry = mappings.find(m => m.username.toLowerCase() === username.toLowerCase());
if (entry) uuid = entry.uuid;
}
showCustomConfirm( // Check if password-protected
message, let isProtected = false;
title, if (uuid && window.electronAPI && window.electronAPI.checkPasswordStatus) {
async () => { try {
await performDeleteUuid(username); const pwStatus = await window.electronAPI.checkPasswordStatus(uuid);
}, isProtected = pwStatus && pwStatus.hasPassword;
null, } catch (e) {
confirmBtn, console.log('[Identity] Password status check failed:', e.message);
cancelBtn }
); }
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) { } catch (error) {
console.error('Error in deleteUuid:', error); console.error('Error in deleteUuid:', error);
const msg = window.i18n ? window.i18n.t('notifications.uuidDeleteFailed') : 'Failed to delete UUID'; showNotification('Failed to delete identity', 'error');
showNotification(msg, '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) { async function performDeleteUuid(username) {
try { try {
if (window.electronAPI && window.electronAPI.deleteUuidForUser) { if (window.electronAPI && window.electronAPI.deleteUuidForUser) {
const result = await window.electronAPI.deleteUuidForUser(username); const result = await window.electronAPI.deleteUuidForUser(username);
if (result.success) { if (result.success) {
const msg = window.i18n ? window.i18n.t('notifications.uuidDeleteSuccess') : 'UUID deleted successfully!'; showNotification('Identity deleted successfully!', 'success');
showNotification(msg, 'success');
await loadAllUuids(); await loadAllUuids();
if (window.loadIdentities) window.loadIdentities(); if (window.loadIdentities) window.loadIdentities();
updatePasswordShieldIcon();
} else { } else {
throw new Error(result.error || 'Failed to delete UUID'); throw new Error(result.error || 'Failed to delete identity');
} }
} }
} catch (error) { } catch (error) {
console.error('Error deleting UUID:', 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(`Failed to delete identity: ${error.message}`, 'error');
showNotification(msg, 'error');
} }
} }

View File

@@ -6368,6 +6368,79 @@ input[type="text"].uuid-input,
color: #22c55e; color: #22c55e;
} }
.identity-btn .password-shield {
position: relative;
font-size: 0.8rem;
padding: 4px 6px;
border-radius: 6px;
transition: all 0.2s ease;
cursor: pointer;
z-index: 10;
}
.identity-btn .password-shield.unprotected {
color: #f59e0b;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
animation: shieldPulse 2s ease-in-out infinite;
}
.identity-btn .password-shield.protected {
color: #22c55e;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
animation: none;
}
.identity-btn .password-shield:hover {
transform: scale(1.15);
filter: brightness(1.3);
}
.identity-btn .password-shield::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
color: #fff;
padding: 6px 10px;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 400;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
border: 1px solid rgba(255,255,255,0.1);
}
.identity-btn .password-shield:hover::after {
opacity: 1;
}
@keyframes shieldPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Password status in identity dropdown */
.identity-item .pw-badge {
font-size: 0.65rem;
margin-left: auto;
padding: 1px 5px;
border-radius: 4px;
}
.identity-item .pw-badge.locked {
color: #22c55e;
background: rgba(34, 197, 94, 0.15);
}
.identity-item .pw-badge.unlocked {
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
}
.identity-dropdown { .identity-dropdown {
position: absolute; position: absolute;
top: 100%; top: 100%;

View File

@@ -529,7 +529,7 @@ function getAllUuidMappingsArray() {
* Validates UUID format before saving * Validates UUID format before saving
* Preserves original case of username * Preserves original case of username
*/ */
function setUuidForUser(username, uuid) { function setUuidForUser(username, uuid, { force = false } = {}) {
const { validate: validateUuid } = require('uuid'); const { validate: validateUuid } = require('uuid');
if (!username || typeof username !== 'string' || !username.trim()) { if (!username || typeof username !== 'string' || !username.trim()) {
@@ -543,15 +543,29 @@ function setUuidForUser(username, uuid) {
const displayName = username.trim(); const displayName = username.trim();
const normalizedLookup = displayName.toLowerCase(); const normalizedLookup = displayName.toLowerCase();
// 1. Update UUID store (source of truth) // 1. Check for existing entries — reject overwrite unless forced
migrateUuidStoreIfNeeded(); migrateUuidStoreIfNeeded();
const uuidStore = loadUuidStore(); const uuidStore = loadUuidStore();
const storeKey = Object.keys(uuidStore).find(k => k.toLowerCase() === normalizedLookup); 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]; if (storeKey) delete uuidStore[storeKey];
uuidStore[displayName] = uuid; uuidStore[displayName] = uuid;
saveUuidStore(uuidStore); saveUuidStore(uuidStore);
// 2. Update config.json (backward compat) // 3. Update config.json (backward compat)
const config = loadConfig(); const config = loadConfig();
const userUuids = config.userUuids || {}; const userUuids = config.userUuids || {};
const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup); const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup);
@@ -560,7 +574,7 @@ function setUuidForUser(username, uuid) {
saveConfig({ userUuids }); saveConfig({ userUuids });
console.log(`[Config] UUID set for "${displayName}": ${uuid}`); 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 { v4: uuidv4 } = require('uuid');
const newUuid = uuidv4(); const newUuid = uuidv4();
return setUuidForUser(username, newUuid); return setUuidForUser(username, newUuid, { force: true });
} }
// ============================================================================= // =============================================================================

View File

@@ -43,24 +43,51 @@ try {
const execAsync = promisify(exec); const execAsync = promisify(exec);
// Fetch tokens from the auth server (properly signed with server's Ed25519 key) // 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(); const authServerUrl = getAuthServerUrl();
try { try {
console.log(`Fetching auth tokens from ${authServerUrl}/game-session/child`); 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`, { const response = await fetch(`${authServerUrl}/game-session/child`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify(bodyData)
uuid: uuid,
name: name,
scopes: ['hytale:server', 'hytale:client']
})
}); });
if (!response.ok) { 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}`); 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') { if (payload.username && payload.username !== name && name !== 'Player') {
console.warn(`[Auth] Token username mismatch: token has "${payload.username}", expected "${name}". Retrying...`); console.warn(`[Auth] Token username mismatch: token has "${payload.username}", expected "${name}". Retrying...`);
// Retry once with explicit name // 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`, { const retryResponse = await fetch(`${authServerUrl}/game-session/child`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: uuid, name: name, scopes: ['hytale:server', 'hytale:client'] }) body: JSON.stringify(retryBody)
}); });
if (retryResponse.ok) { if (retryResponse.ok) {
const retryData = await retryResponse.json(); const retryData = await retryResponse.json();
@@ -99,6 +128,10 @@ async function fetchAuthTokens(uuid, name) {
console.log('Auth tokens received from server'); console.log('Auth tokens received from server');
return { identityToken, sessionToken }; return { identityToken, sessionToken };
} catch (error) { } 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); console.error('Failed to fetch auth tokens:', error.message);
// Fallback to local generation if server unavailable // Fallback to local generation if server unavailable
return generateLocalTokens(uuid, name); 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 // CACHE INVALIDATION: Clear proxyClient module cache to force fresh .env load
// This prevents stale cached values from affecting multiple launch attempts // 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); const uuid = getUuidForUser(playerName);
console.log(`[Launcher] UUID for "${playerName}": ${uuid} (verify this stays constant across launches)`); 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) { if (progressCallback) {
progressCallback('Fetching authentication tokens...', null, null, null, null); 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) // Patch client and server binaries to use custom auth server (BEFORE signing on macOS)
// FORCE patch on every launch to ensure consistency // FORCE patch on every launch to ensure consistency
@@ -482,11 +516,15 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
} }
} }
// DualAuth Agent: DISABLED for debug - testing fastutil classloader issue // DualAuth Agent: Set JAVA_TOOL_OPTIONS so java picks up -javaagent: flag
// TODO: re-enable after testing // This enables runtime auth patching without modifying the server JAR
const agentJar = path.join(gameLatest, 'Server', 'dualauth-agent.jar'); const agentJar = path.join(gameLatest, 'Server', 'dualauth-agent.jar');
if (fs.existsSync(agentJar)) { 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 { try {
@@ -574,7 +612,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 { try {
// ========================================================================== // ==========================================================================
// PRE-LAUNCH VALIDATION: Check username is configured // PRE-LAUNCH VALIDATION: Check username is configured
@@ -647,7 +685,7 @@ async function launchGameWithVersionCheck(playerNameOverride = null, progressCal
progressCallback('Launching game...', 80, null, null, null); 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 // Ensure we always return a result
if (!launchResult) { if (!launchResult) {
@@ -661,6 +699,10 @@ async function launchGameWithVersionCheck(playerNameOverride = null, progressCal
if (progressCallback) { if (progressCallback) {
progressCallback(`Error: ${error.message}`, -1, null, null, null); 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 // Always return an error response instead of throwing
return { success: false, error: error.message || 'Unknown launch error' }; return { success: false, error: error.message || 'Unknown launch error' };
} }

View File

@@ -1,10 +1,10 @@
# Singleplayer Server Crash: fastutil ClassNotFoundException # Singleplayer Server Crash: fastutil ClassNotFoundException
## Status: Open (multiple users, Feb 24-27 2026) ## Status: Open — likely outdated HytaleServer.jar (Feb 24-28 2026)
## Symptom ## 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 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 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 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 - Reproduces 100% on singleplayer, every attempt (users 1-2)
- Multiplayer works fine for both users - Multiplayer works fine for users 1-2
- macOS/Linux users are NOT affected - macOS/Linux users are NOT affected
## What Works ## Ruled Out (confirmed via debug builds)
- Java wrapper correctly strips `-XX:+UseCompactObjectHeaders` | Suspect | Tested | Result |
- Java wrapper correctly injects `--disable-sentry` |---------|--------|--------|
- DualAuth Agent v1.1.12 installs successfully (STATIC mode) | **DualAuth Agent** | Debug build with agent completely disabled (`debug-no-agent` tag) | **Same crash.** Agent is innocent. |
- Multiplayer connections work fine | **`-Xshare:off` (CDS)** | Added to `JAVA_TOOL_OPTIONS` in launcher code (`debug-xshare-off` tag) | **Did not help.** CDS is not the cause. |
- Repair and reinstall did NOT fix the issue | **`-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) ## What We Know
- **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
### 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 ### Standalone server users
1. Download fresh `HytaleServer.jar` from current game version
1. **Antivirus interference** — AV blocking Java from reading classes out of JAR files 2. Replace the old JAR file
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
## Update History ## Update History
### Feb 24: `-XX:+UseCompactObjectHeaders` stripping removed from defaults ### Feb 24: First report (JAYED)
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`. 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 ### Feb 27: Second report (Asentrix), extensive debugging
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. - 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 ### Feb 28: Third user (7645754) — FIXED by replacing HytaleServer.jar
- Standalone server user had same crash on localhost, VPS worked fine
If a user needs to strip a specific JVM flag (e.g., for debugging or compatibility), they can do it via the launcher UI: - **Fixed by updating `HytaleServer.jar` to match VPS version**
- Root cause likely: outdated JAR incompatible with current/updated JRE
1. Open **Settings** → scroll to **Java Wrapper Configuration** - For F2P launcher users: need to delete game folder and force fresh re-download
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" }
]
}
}
```
## Related ## Related
- Java wrapper config: `backend/core/config.js` (stripFlags / injectArgs) - Java wrapper config: `backend/core/config.js` (stripFlags / injectArgs)
- DualAuth Agent: v1.1.12, package `ws.sanasol.dualauth` - DualAuth Agent: v1.1.12, package `ws.sanasol.dualauth`
- Game version at time of reports: `2026.02.19-1a311a592` - 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)

168
main.js
View File

@@ -530,7 +530,26 @@ 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) { if (result.success && result.launched) {
const closeOnStart = loadCloseLauncherOnStart(); const closeOnStart = loadCloseLauncherOnStart();
@@ -554,10 +573,68 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g
}, 2000); }, 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 }; 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) {
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) => { ipcMain.handle('install-game', async (event, playerName, javaPath, installPath, branch) => {
try { try {
console.log(`[IPC] install-game called with parameters:`); console.log(`[IPC] install-game called with parameters:`);
@@ -1352,9 +1429,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 { 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 }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error setting UUID for user:', error); console.error('Error setting UUID for user:', error);
@@ -1391,6 +1471,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) => { ipcMain.handle('get-recent-logs', async (event, maxLines = 100) => {
try { try {
const logDir = logger.getLogDirectory(); const logDir = logger.getLogDirectory();

View File

@@ -1,6 +1,6 @@
{ {
"name": "hytale-f2p-launcher", "name": "hytale-f2p-launcher",
"version": "2.4.5", "version": "2.4.6",
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support", "description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
"homepage": "https://git.sanhost.net/sanasol/hytale-f2p", "homepage": "https://git.sanhost.net/sanasol/hytale-f2p",
"main": "main.js", "main": "main.js",

View File

@@ -2,6 +2,7 @@ const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
launchGame: (playerName, javaPath, installPath, gpuPreference) => ipcRenderer.invoke('launch-game', playerName, javaPath, installPath, gpuPreference), 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), installGame: (playerName, javaPath, installPath, branch) => ipcRenderer.invoke('install-game', playerName, javaPath, installPath, branch),
closeWindow: () => ipcRenderer.invoke('window-close'), closeWindow: () => ipcRenderer.invoke('window-close'),
minimizeWindow: () => ipcRenderer.invoke('window-minimize'), minimizeWindow: () => ipcRenderer.invoke('window-minimize'),
@@ -102,11 +103,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
// UUID Management methods // UUID Management methods
getCurrentUuid: () => ipcRenderer.invoke('get-current-uuid'), getCurrentUuid: () => ipcRenderer.invoke('get-current-uuid'),
getAllUuidMappings: () => ipcRenderer.invoke('get-all-uuid-mappings'), 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'), generateNewUuid: () => ipcRenderer.invoke('generate-new-uuid'),
deleteUuidForUser: (username) => ipcRenderer.invoke('delete-uuid-for-user', username), deleteUuidForUser: (username) => ipcRenderer.invoke('delete-uuid-for-user', username),
resetCurrentUserUuid: () => ipcRenderer.invoke('reset-current-user-uuid'), 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 // Java Wrapper Config API
loadWrapperConfig: () => ipcRenderer.invoke('load-wrapper-config'), loadWrapperConfig: () => ipcRenderer.invoke('load-wrapper-config'),
saveWrapperConfig: (config) => ipcRenderer.invoke('save-wrapper-config', config), saveWrapperConfig: (config) => ipcRenderer.invoke('save-wrapper-config', config),