Compare commits

..

2 Commits

Author SHA1 Message Date
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
8 changed files with 853 additions and 101 deletions

View File

@@ -77,6 +77,7 @@
<button class="identity-btn" onclick="toggleIdentityDropdown()">
<i class="fas fa-id-badge"></i>
<span id="currentIdentityName">Player</span>
<i id="passwordShieldIcon" class="fas fa-unlock password-shield unprotected" data-tooltip="Click to protect identity" onclick="event.stopPropagation(); openPasswordModal()"></i>
<i class="fas fa-chevron-down"></i>
</button>
<div class="identity-dropdown" id="identityDropdown">
@@ -830,6 +831,41 @@
</p>
</div>
</div>
<div class="uuid-advanced-section" style="margin-top: 12px;">
<button id="passwordSectionToggle" class="uuid-advanced-toggle">
<i class="fas fa-chevron-right uuid-advanced-chevron"></i>
<span>Password Protection</span>
</button>
<div id="passwordSectionContent" class="uuid-advanced-content" style="display: none;">
<h3 class="uuid-section-title">Protect Your Identity</h3>
<p class="uuid-custom-hint" style="margin-bottom: 12px;">
<i class="fas fa-shield-alt"></i>
<span>Set a password to prevent others from using your UUID</span>
</p>
<div id="passwordStatusMsg" class="uuid-custom-hint" style="margin-bottom: 8px; color: #93a3b8;"></div>
<div id="passwordSetForm">
<div class="uuid-custom-form" style="margin-bottom: 8px;">
<input type="password" id="currentPasswordInput" class="uuid-input"
placeholder="Current password" style="display: none;" />
</div>
<div class="uuid-custom-form" style="margin-bottom: 8px;">
<input type="password" id="newPasswordInput" class="uuid-input"
placeholder="New password (min 6 chars)" />
</div>
<div class="uuid-custom-form">
<button id="setPasswordBtn" class="uuid-set-btn" onclick="handleSetPassword()">
<i class="fas fa-lock"></i>
<span>Set Password</span>
</button>
<button id="removePasswordBtn" class="uuid-cancel-btn" onclick="handleRemovePassword()" style="display: none;">
<i class="fas fa-unlock"></i>
<span>Remove Password</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -861,6 +897,59 @@
</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">
<i class="fas fa-code-branch"></i>
<span id="launcherVersion"></span>

View File

@@ -292,6 +292,25 @@ export async function launch() {
}
resetPlayButton();
if (result.usernameTaken) {
// Username reserved by another player
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.passwordRequired) {
// UUID has a password — show interactive password dialog
const launchResult = await promptForPasswordAndLaunch(playerName, javaPath, gpuPreference);
if (launchResult && launchResult.success) {
if (window.electronAPI.minimizeWindow) setTimeout(() => { window.electronAPI.minimizeWindow(); }, 500);
}
return;
}
if (result.success) {
if (window.electronAPI.minimizeWindow) {
setTimeout(() => {
@@ -354,6 +373,177 @@ export async function launch() {
}
}
function promptForPasswordAndLaunch(playerName, javaPath, gpuPreference) {
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 />
</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');
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();
isDownloading = true;
const playBtn = document.getElementById('play-btn');
const playText = playBtn?.querySelector('.play-text');
if (playBtn) { playBtn.disabled = true; }
if (playText) { playText.textContent = 'LAUNCHING...'; }
const result = await window.electronAPI.launchGameWithPassword(playerName, javaPath, '', gpuPreference, password);
if (result.success) {
overlay.remove();
isDownloading = false;
if (window.LauncherUI) window.LauncherUI.hideProgress();
resetPlayButton();
resolve(result);
return;
}
// Wrong password
if (result.passwordRequired) {
showError(result.error || 'Incorrect password. Please try again.');
} else {
showError(result.error || 'Launch failed.');
}
isDownloading = false;
if (window.LauncherUI) window.LauncherUI.hideProgress();
resetPlayButton();
} catch (err) {
showError(err.message || 'An error occurred.');
isDownloading = false;
if (window.LauncherUI) window.LauncherUI.hideProgress();
resetPlayButton();
} finally {
busy = false;
confirmBtn.disabled = false;
confirmBtn.textContent = 'Login';
}
};
confirmBtn.addEventListener('click', tryLogin);
cancelBtn.addEventListener('click', () => close(null));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') tryLogin();
if (e.key === 'Escape') close(null);
});
setTimeout(() => input.focus(), 100);
});
}
function showCustomConfirm(message, title, onConfirm, onCancel = null, confirmText, cancelText) {
// Apply defaults with i18n support
title = title || (window.i18n ? window.i18n.t('confirm.defaultTitle') : 'Confirm Action');
@@ -712,7 +902,7 @@ async function loadIdentities() {
}
}
function renderIdentityList(mappings, currentUsername) {
async function renderIdentityList(mappings, currentUsername) {
const list = document.getElementById('identityList');
if (!list) return;
@@ -721,13 +911,31 @@ function renderIdentityList(mappings, currentUsername) {
return;
}
list.innerHTML = mappings.map(m => {
// Check password status for all identities in parallel
const statusChecks = mappings.map(async m => {
try {
if (m.uuid && window.electronAPI?.checkPasswordStatus) {
const s = await window.electronAPI.checkPasswordStatus(m.uuid);
return s?.hasPassword || false;
}
} catch {}
return false;
});
const statuses = await Promise.all(statusChecks);
list.innerHTML = mappings.map((m, i) => {
const safe = escapeHtml(m.username);
const isActive = m.username === currentUsername;
const hasPassword = statuses[i];
const pwBadge = hasPassword
? '<span class="pw-badge locked"><i class="fas fa-lock"></i></span>'
: '<span class="pw-badge unlocked"><i class="fas fa-unlock"></i></span>';
return `
<div class="identity-item ${m.username === currentUsername ? 'active' : ''}"
<div class="identity-item ${isActive ? 'active' : ''}"
onclick="switchIdentity('${safe.replace(/'/g, "&#39;")}')">
<span>${safe}</span>
${m.username === currentUsername ? '<i class="fas fa-check ml-auto"></i>' : ''}
${pwBadge}
${isActive ? '<i class="fas fa-check" style="margin-left:4px;"></i>' : ''}
</div>
`;
}).join('');
@@ -774,6 +982,9 @@ window.switchIdentity = async (username) => {
if (settingsInput) settingsInput.value = username;
if (window.loadCurrentUuid) window.loadCurrentUuid();
// Update password shield icon for new identity
if (window.updatePasswordShieldIcon) window.updatePasswordShieldIcon();
} catch (error) {
console.error('Failed to switch identity:', error);
}

View File

@@ -941,6 +941,230 @@ 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');
if (!newPw || !newPw.value || newPw.value.length < 6) {
showNotification('Password must be at least 6 characters', 'error');
return;
}
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();
} else {
showNotification(result.error || 'Failed to set password', 'error');
}
} catch (e) {
showNotification('Error: ' + e.message, 'error');
}
};
window.handleRemovePassword = async function () {
const currentPw = document.getElementById('currentPasswordInput');
if (!currentPw || !currentPw.value) {
showNotification('Enter your current password to remove it', 'error');
return;
}
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();
} else {
showNotification(result.error || 'Failed to remove password', 'error');
}
} catch (e) {
showNotification('Error: ' + e.message, 'error');
}
};
// ─── 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');
if (!newPw || !newPw.value || newPw.value.length < 6) {
showNotification('Password must be at least 6 characters', 'error');
return;
}
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();
openPasswordModal(); // refresh modal state
} else {
showNotification(result.error || 'Failed to set password', 'error');
}
} catch (e) {
showNotification('Error: ' + e.message, 'error');
}
};
window.handlePasswordModalRemove = async function () {
const curPw = document.getElementById('pwModalCurrentPassword');
if (!curPw || !curPw.value) {
showNotification('Enter your current password to remove it', 'error');
return;
}
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();
openPasswordModal(); // refresh modal state
} else {
showNotification(result.error || 'Failed to remove password', 'error');
}
} catch (e) {
showNotification('Error: ' + e.message, 'error');
}
};
// Close modal on backdrop click
document.addEventListener('click', (e) => {
const modal = document.getElementById('passwordModal');
if (modal && e.target === modal) closePasswordModal();
});
// Bind password section toggle (for legacy UUID modal section)
document.addEventListener('DOMContentLoaded', () => {
const pwToggle = document.getElementById('passwordSectionToggle');
if (pwToggle) pwToggle.addEventListener('click', togglePasswordSection);
updatePasswordShieldIcon();
});
window.regenerateUuidForUser = async function (username) {
try {
const message = window.i18n ? window.i18n.t('confirm.regenerateUuidMessage') : 'Are you sure you want to generate a new UUID? This will change your player identity.';

View File

@@ -6368,6 +6368,79 @@ input[type="text"].uuid-input,
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 {
position: absolute;
top: 100%;

View File

@@ -43,24 +43,45 @@ try {
const execAsync = promisify(exec);
// Fetch tokens from the auth server (properly signed with server's Ed25519 key)
async function fetchAuthTokens(uuid, name) {
async function fetchAuthTokens(uuid, name, password) {
const authServerUrl = getAuthServerUrl();
try {
console.log(`Fetching auth tokens from ${authServerUrl}/game-session/child`);
const bodyData = {
uuid: uuid,
name: name,
scopes: ['hytale:server', 'hytale:client']
};
if (password) bodyData.password = password;
const response = await fetch(`${authServerUrl}/game-session/child`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
uuid: uuid,
name: name,
scopes: ['hytale:server', 'hytale:client']
})
body: JSON.stringify(bodyData)
});
if (!response.ok) {
const errBody = await response.json().catch(() => ({}));
if (response.status === 401 && errBody.password_required) {
const err = new Error('Password required');
err.passwordRequired = true;
err.attemptsRemaining = errBody.attemptsRemaining;
throw err;
}
if (response.status === 429) {
const err = new Error('Too many failed attempts. Try again later.');
err.lockedOut = true;
err.lockoutSeconds = errBody.lockoutSeconds;
throw err;
}
if (response.status === 403 && errBody.username_taken) {
const err = new Error('This username is reserved by another player who has set a password. Please use a different name.');
err.usernameTaken = true;
throw err;
}
throw new Error(`Auth server returned ${response.status}`);
}
@@ -77,10 +98,12 @@ async function fetchAuthTokens(uuid, name) {
if (payload.username && payload.username !== name && name !== 'Player') {
console.warn(`[Auth] Token username mismatch: token has "${payload.username}", expected "${name}". Retrying...`);
// Retry once with explicit name
const retryBody = { uuid: uuid, name: name, scopes: ['hytale:server', 'hytale:client'] };
if (password) retryBody.password = password;
const retryResponse = await fetch(`${authServerUrl}/game-session/child`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: uuid, name: name, scopes: ['hytale:server', 'hytale:client'] })
body: JSON.stringify(retryBody)
});
if (retryResponse.ok) {
const retryData = await retryResponse.json();
@@ -99,6 +122,10 @@ async function fetchAuthTokens(uuid, name) {
console.log('Auth tokens received from server');
return { identityToken, sessionToken };
} catch (error) {
// Re-throw authentication errors — must not fall back to local tokens
if (error.passwordRequired || error.lockedOut || error.usernameTaken) {
throw error;
}
console.error('Failed to fetch auth tokens:', error.message);
// Fallback to local generation if server unavailable
return generateLocalTokens(uuid, name);
@@ -147,7 +174,7 @@ function generateLocalTokens(uuid, name) {
};
}
async function launchGame(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
async function launchGame(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null, options = {}) {
// ==========================================================================
// CACHE INVALIDATION: Clear proxyClient module cache to force fresh .env load
// This prevents stale cached values from affecting multiple launch attempts
@@ -256,11 +283,12 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
const uuid = getUuidForUser(playerName);
console.log(`[Launcher] UUID for "${playerName}": ${uuid} (verify this stays constant across launches)`);
// Fetch tokens from auth server
// Fetch tokens from auth server (with password if provided)
if (progressCallback) {
progressCallback('Fetching authentication tokens...', null, null, null, null);
}
const { identityToken, sessionToken } = await fetchAuthTokens(uuid, playerName);
const launchPassword = options?.password || null;
const { identityToken, sessionToken } = await fetchAuthTokens(uuid, playerName, launchPassword);
// Patch client and server binaries to use custom auth server (BEFORE signing on macOS)
// FORCE patch on every launch to ensure consistency
@@ -482,11 +510,15 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
}
}
// DualAuth Agent: DISABLED for debug - testing fastutil classloader issue
// TODO: re-enable after testing
// DualAuth Agent: Set JAVA_TOOL_OPTIONS so java picks up -javaagent: flag
// This enables runtime auth patching without modifying the server JAR
const agentJar = path.join(gameLatest, 'Server', 'dualauth-agent.jar');
if (fs.existsSync(agentJar)) {
console.log('DualAuth Agent: SKIPPED (debug build - fastutil classloader test)');
const agentFlag = `-javaagent:"${agentJar}"`;
env.JAVA_TOOL_OPTIONS = env.JAVA_TOOL_OPTIONS
? `${env.JAVA_TOOL_OPTIONS} ${agentFlag}`
: agentFlag;
console.log('DualAuth Agent: enabled via JAVA_TOOL_OPTIONS');
}
try {
@@ -574,7 +606,7 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
}
}
async function launchGameWithVersionCheck(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
async function launchGameWithVersionCheck(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null, options = {}) {
try {
// ==========================================================================
// PRE-LAUNCH VALIDATION: Check username is configured
@@ -647,7 +679,7 @@ async function launchGameWithVersionCheck(playerNameOverride = null, progressCal
progressCallback('Launching game...', 80, null, null, null);
}
const launchResult = await launchGame(playerNameOverride, progressCallback, javaPathOverride, installPathOverride, gpuPreference, branch);
const launchResult = await launchGame(playerNameOverride, progressCallback, javaPathOverride, installPathOverride, gpuPreference, branch, options);
// Ensure we always return a result
if (!launchResult) {
@@ -661,6 +693,10 @@ async function launchGameWithVersionCheck(playerNameOverride = null, progressCal
if (progressCallback) {
progressCallback(`Error: ${error.message}`, -1, null, null, null);
}
// Re-throw authentication errors so IPC handler can return proper flags
if (error.passwordRequired || error.lockedOut || error.usernameTaken) {
throw error;
}
// Always return an error response instead of throwing
return { success: false, error: error.message || 'Unknown launch error' };
}

View File

@@ -1,10 +1,10 @@
# Singleplayer Server Crash: fastutil ClassNotFoundException
## Status: Open (multiple users, Feb 24-27 2026)
## Status: Open — NO SOLUTION (Feb 24-27 2026)
## Symptom
Singleplayer server crashes immediately after DualAuth Agent installs successfully:
Singleplayer server crashes immediately on boot:
```
Exception in thread "main" java.lang.NoClassDefFoundError: it/unimi/dsi/fastutil/objects/ObjectArrayList
@@ -25,99 +25,55 @@ Server exits with code 1. Multiplayer works fine for the same user.
- Multiplayer works fine for both users
- macOS/Linux users are NOT affected
## What Works
## Ruled Out (confirmed via debug builds)
- Java wrapper correctly strips `-XX:+UseCompactObjectHeaders`
- Java wrapper correctly injects `--disable-sentry`
- DualAuth Agent v1.1.12 installs successfully (STATIC mode)
- Multiplayer connections work fine
- Repair and reinstall did NOT fix the issue
| Suspect | Tested | Result |
|---------|--------|--------|
| **DualAuth Agent** | Debug build with agent completely disabled (`debug-no-agent` tag) | **Same crash.** Agent is innocent. |
| **`-Xshare:off` (CDS)** | Added to `JAVA_TOOL_OPTIONS` in launcher code (`debug-xshare-off` tag) | **Did not help.** CDS is not the cause. |
| **`-XX:+UseCompactObjectHeaders`** | Stripped via wrapper | **Did not help.** Server has `-XX:+IgnoreUnrecognizedVMOptions` anyway. |
| **Corrupted game files** | User did repair + full reinstall | **Same crash.** |
| **Java wrapper** | Logs confirm wrapper works correctly | Not the cause. |
| **ARM64/Parallels** | User is on standard Windows x86_64 | Not applicable. |
| **AOT cache** | Asentrix has no AOT errors (JAYED did), both crash the same way | Not the root cause. |
## Root Cause Analysis
## What We Know
`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.
- `fastutil` is bundled inside `HytaleServer.jar` (fat/shaded JAR) — same JAR for all users
- 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
### Ruled Out
## Remaining Theories
- **Wrapper issue**: Wrapper is working correctly (confirmed in logs)
- **UseCompactObjectHeaders**: Server also has `-XX:+IgnoreUnrecognizedVMOptions`, so unrecognized flags don't crash it
- **DualAuth Agent**: Works for all other users; agent installs successfully before the crash
- **Corrupted game files**: Repair/reinstall didn't help
- **ARM64/Parallels**: User is on standard Windows, not ARM
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.
### Likely Cause
## Next Steps to Try
**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.
This warning appears for ALL users, but only breaks classloading on some Windows systems — reason unknown.
### Other Possible Causes
1. **Antivirus interference** — AV blocking Java from reading classes out of JAR files
2. **File locking** — another process holding HytaleServer.jar open (Asentrix had stalled java.exe killed at launch)
## Potential Fix: `-Xshare:off` (testing Feb 27)
Disables CDS entirely, forcing standard classloading. User can add via launcher:
1. **Settings****Java Wrapper Configuration****Arguments to Inject**
2. Add `-Xshare:off` with **Server Only** condition
3. Retry singleplayer
Sent to affected users for testing — **awaiting results**.
If confirmed, should be added as default inject arg (server-only) in launcher config.
## Debugging Steps (for reference)
Most steps are impractical for F2P users:
- ~~Official Hytale singleplayer~~ — F2P users don't have official access
- ~~Try without DualAuth agent~~ — not possible, agent required for F2P token validation
- ~~Verify fastutil in JAR~~ — same JAR for all users, not a user-actionable step
- ~~Check JRE version~~ — bundled with launcher, same for all users
**Practical steps:**
1. **Add `-Xshare:off`** via wrapper inject args (server-only) — testing now
2. **Check antivirus** — add game directory to Windows Defender exclusions
3. **Check for stalled processes** — kill any leftover java.exe/HytaleServer before launch
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)
## Update History
### Feb 24: `-XX:+UseCompactObjectHeaders` stripping removed from defaults
Stripping this flag did NOT fix the issue. The server already has `-XX:+IgnoreUnrecognizedVMOptions` so unrecognized flags are harmless. The flag was removed from default `stripFlags` in `backend/core/config.js`.
### Feb 24: First report (JAYED)
User reported singleplayer crash. Initial investigation found AOT cache errors + fastutil ClassNotFoundException. Stripping `-XX:+UseCompactObjectHeaders` did not help.
### Feb 27: Second user (Asentrix) reported, `-Xshare:off` sent for testing
Asentrix hit the same crash on Launcher v2.4.4. Unlike JAYED, no AOT cache errors — just the CDS sharing warning followed by fastutil ClassNotFoundException. This confirms the issue is not AOT-specific but related to CDS/classloader interaction with the DualAuth agent's bootstrap CL modification. Sent `-Xshare:off` workaround to affected users — awaiting results.
## Using the Java Wrapper to Strip JVM Flags
If a user needs to strip a specific JVM flag (e.g., for debugging or compatibility), they can do it via the launcher UI:
1. Open **Settings** → scroll to **Java Wrapper Configuration**
2. Under **JVM Flags to Remove**, type the flag (e.g. `-XX:+UseCompactObjectHeaders`) and click **Add**
3. The flag will be stripped from all JVM invocations at launch time
4. To inject custom arguments, use the **Arguments to Inject** section (with optional "Server Only" condition)
5. **Restore Defaults** resets to empty strip flags + `--disable-sentry` (server only)
The wrapper generates platform-specific scripts at launch time:
- **Windows**: `java-wrapper.bat` in `jre/latest/bin/`
- **macOS/Linux**: `java-wrapper` shell script in the same directory
Config is stored in `config.json` under `javaWrapperConfig`:
```json
{
"javaWrapperConfig": {
"stripFlags": ["-XX:+SomeFlag"],
"injectArgs": [
{ "arg": "--some-arg", "condition": "server" },
{ "arg": "--other-arg", "condition": "always" }
]
}
}
```
### Feb 27: Second report (Asentrix), extensive debugging
- Asentrix hit same crash, no AOT errors — ruled out AOT as root cause
- Built `debug-xshare-off`: added `-Xshare:off` to `JAVA_TOOL_OPTIONS`**did not help**
- Built `debug-no-agent`: completely disabled DualAuth agent — **same crash**
- **Conclusion**: Neither the agent nor CDS is the cause. The JVM itself cannot load classes from the fat JAR on these specific Windows systems.
- Note: wrapper `injectArgs` append AFTER `-jar`, so they cannot inject JVM flags — only `JAVA_TOOL_OPTIONS` works for JVM flags
## Related
- Java wrapper config: `backend/core/config.js` (stripFlags / injectArgs)
- DualAuth Agent: v1.1.12, package `ws.sanasol.dualauth`
- Game version at time of reports: `2026.02.19-1a311a592`
- Log submission ID (Asentrix): `c88e7b71`
- Debug tags: `debug-xshare-off`, `debug-no-agent`
- Log submission IDs: `c88e7b71` (Asentrix initial), `0445e4dc` (xshare test), `748dceeb` (no-agent test)

155
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 = {};
try {
const { getAuthServerUrl } = require('./backend/core/config');
const { getUuidForUser } = require('./backend/core/config');
const uuid = getUuidForUser(playerName);
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) {
const closeOnStart = loadCloseLauncherOnStart();
@@ -554,10 +573,62 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g
}, 2000);
}
if (error.passwordRequired) {
return { success: false, passwordRequired: true, 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 };
}
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() };
}
return { success: false, error: error.message || error.toString() };
}
});
ipcMain.handle('install-game', async (event, playerName, javaPath, installPath, branch) => {
try {
console.log(`[IPC] install-game called with parameters:`);
@@ -1391,6 +1462,88 @@ ipcMain.handle('reset-current-user-uuid', async () => {
}
});
// Password Management IPC handlers
ipcMain.handle('check-password-status', async (event, uuid) => {
try {
const { getAuthServerUrl } = require('./backend/core/config');
const authServerUrl = getAuthServerUrl();
const response = await fetch(`${authServerUrl}/player/password/status/${uuid}`);
if (!response.ok) return { hasPassword: false };
return await response.json();
} catch (error) {
console.error('Error checking password status:', error);
return { hasPassword: false, error: error.message };
}
});
ipcMain.handle('set-player-password', async (event, uuid, password, currentPassword) => {
try {
const { getAuthServerUrl } = require('./backend/core/config');
const { getUuidForUser, loadUsername } = require('./backend/core/config');
const authServerUrl = getAuthServerUrl();
// First get a bearer token for auth
const name = loadUsername() || 'Player';
const tokenResp = await fetch(`${authServerUrl}/game-session/child`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid, name, password: currentPassword || undefined })
});
if (!tokenResp.ok) {
const err = await tokenResp.json().catch(() => ({}));
return { success: false, error: err.error || 'Failed to authenticate' };
}
const tokenData = await tokenResp.json();
const bearerToken = tokenData.identityToken || tokenData.IdentityToken;
const response = await fetch(`${authServerUrl}/player/password/set`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${bearerToken}`
},
body: JSON.stringify({ uuid, password, currentPassword: currentPassword || undefined })
});
return await response.json();
} catch (error) {
console.error('Error setting password:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('remove-player-password', async (event, uuid, currentPassword) => {
try {
const { getAuthServerUrl } = require('./backend/core/config');
const { loadUsername } = require('./backend/core/config');
const authServerUrl = getAuthServerUrl();
const name = loadUsername() || 'Player';
// Get bearer token with current password
const tokenResp = await fetch(`${authServerUrl}/game-session/child`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid, name, password: currentPassword })
});
if (!tokenResp.ok) {
const err = await tokenResp.json().catch(() => ({}));
return { success: false, error: err.error || 'Failed to authenticate' };
}
const tokenData = await tokenResp.json();
const bearerToken = tokenData.identityToken || tokenData.IdentityToken;
const response = await fetch(`${authServerUrl}/player/password/remove`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${bearerToken}`
},
body: JSON.stringify({ uuid, currentPassword })
});
return await response.json();
} catch (error) {
console.error('Error removing password:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('get-recent-logs', async (event, maxLines = 100) => {
try {
const logDir = logger.getLogDirectory();

View File

@@ -2,6 +2,7 @@ const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
launchGame: (playerName, javaPath, installPath, gpuPreference) => ipcRenderer.invoke('launch-game', playerName, javaPath, installPath, gpuPreference),
launchGameWithPassword: (playerName, javaPath, installPath, gpuPreference, password) => ipcRenderer.invoke('launch-game-with-password', playerName, javaPath, installPath, gpuPreference, password),
installGame: (playerName, javaPath, installPath, branch) => ipcRenderer.invoke('install-game', playerName, javaPath, installPath, branch),
closeWindow: () => ipcRenderer.invoke('window-close'),
minimizeWindow: () => ipcRenderer.invoke('window-minimize'),
@@ -107,6 +108,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
deleteUuidForUser: (username) => ipcRenderer.invoke('delete-uuid-for-user', username),
resetCurrentUserUuid: () => ipcRenderer.invoke('reset-current-user-uuid'),
// Password Management methods
checkPasswordStatus: (uuid) => ipcRenderer.invoke('check-password-status', uuid),
setPlayerPassword: (uuid, password, currentPassword) => ipcRenderer.invoke('set-player-password', uuid, password, currentPassword),
removePlayerPassword: (uuid, currentPassword) => ipcRenderer.invoke('remove-player-password', uuid, currentPassword),
promptPassword: () => ipcRenderer.invoke('prompt-password'),
onPasswordPrompt: (callback) => {
ipcRenderer.on('show-password-prompt', (event, data) => callback(data));
},
// Java Wrapper Config API
loadWrapperConfig: () => ipcRenderer.invoke('load-wrapper-config'),
saveWrapperConfig: (config) => ipcRenderer.invoke('save-wrapper-config', config),