Compare commits

..

9 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
sanasol
43a2b6d004 debug: disable DualAuth agent to test fastutil classloader issue
Temporarily skip -javaagent injection to determine if agent's
appendToBootstrapClassLoaderSearch() causes the fastutil
ClassNotFoundException on affected Windows systems.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:47:46 +01:00
sanasol
ce6455314d debug: add -Xshare:off to JAVA_TOOL_OPTIONS to test fastutil classloader fix
Disables JVM Class Data Sharing when DualAuth agent is active.
May fix singleplayer crash (NoClassDefFoundError: fastutil) on some Windows systems
where appendToBootstrapClassLoaderSearch breaks CDS classloading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:32:50 +01:00
sanasol
3abdd10cab v2.4.5: Add multi-instance setting for mod developers
Allow running multiple game clients simultaneously via a new
"Allow multiple game instances" toggle in Settings. When enabled,
skips the Electron single-instance lock and the pre-launch process
kill, so existing game instances stay alive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:52:13 +01:00
sanasol
e1a3f919a2 Fix JRE flatten failing silently on Windows EPERM
When extracting the bundled JRE, flattenJREDir renames files from
the nested jdk subdirectory up one level. On Windows this fails with
EPERM when antivirus or file indexing holds handles open, leaving
the JRE nested and unfindable — causing "Server failed to boot".

- Fall back to copy+delete when rename gets EPERM/EACCES/EBUSY
- getBundledJavaPath checks nested JRE subdirs as last resort
2026-02-26 23:39:43 +01:00
sanasol
e3fe1b6a10 Delete server/commit-msg.txt 2026-02-26 19:45:57 +00:00
24 changed files with 1563 additions and 176 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">
@@ -592,6 +593,18 @@
</div> </div>
</label> </label>
</div> </div>
<div class="settings-option">
<label class="settings-checkbox">
<input type="checkbox" id="allowMultiInstanceCheck" />
<span class="checkmark"></span>
<div class="checkbox-content">
<div class="checkbox-title" data-i18n="settings.allowMultiInstance">Allow multiple game instances</div>
<div class="checkbox-description" data-i18n="settings.allowMultiInstanceDescription">
Allow running multiple game clients at the same time (useful for mod development)
</div>
</div>
</label>
</div>
<div class="settings-option"> <div class="settings-option">
<label class="settings-checkbox"> <label class="settings-checkbox">
<input type="checkbox" id="launcherHwAccelCheck" /> <input type="checkbox" id="launcherHwAccelCheck" />
@@ -818,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>
@@ -849,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);
if (result.usernameTaken) {
// Username reserved by another player
isDownloading = false; isDownloading = false;
if (window.LauncherUI) window.LauncherUI.hideProgress();
if (window.LauncherUI) {
window.LauncherUI.hideProgress();
}
resetPlayButton(); 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;
}
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

@@ -6,6 +6,7 @@ let browseJavaBtn;
let settingsPlayerName; let settingsPlayerName;
let discordRPCCheck; let discordRPCCheck;
let closeLauncherCheck; let closeLauncherCheck;
let allowMultiInstanceCheck;
let launcherHwAccelCheck; let launcherHwAccelCheck;
let gpuPreferenceRadios; let gpuPreferenceRadios;
let gameBranchRadios; let gameBranchRadios;
@@ -171,6 +172,7 @@ function setupSettingsElements() {
settingsPlayerName = document.getElementById('settingsPlayerName'); settingsPlayerName = document.getElementById('settingsPlayerName');
discordRPCCheck = document.getElementById('discordRPCCheck'); discordRPCCheck = document.getElementById('discordRPCCheck');
closeLauncherCheck = document.getElementById('closeLauncherCheck'); closeLauncherCheck = document.getElementById('closeLauncherCheck');
allowMultiInstanceCheck = document.getElementById('allowMultiInstanceCheck');
launcherHwAccelCheck = document.getElementById('launcherHwAccelCheck'); launcherHwAccelCheck = document.getElementById('launcherHwAccelCheck');
gpuPreferenceRadios = document.querySelectorAll('input[name="gpuPreference"]'); gpuPreferenceRadios = document.querySelectorAll('input[name="gpuPreference"]');
gameBranchRadios = document.querySelectorAll('input[name="gameBranch"]'); gameBranchRadios = document.querySelectorAll('input[name="gameBranch"]');
@@ -218,6 +220,10 @@ function setupSettingsElements() {
closeLauncherCheck.addEventListener('change', saveCloseLauncher); closeLauncherCheck.addEventListener('change', saveCloseLauncher);
} }
if (allowMultiInstanceCheck) {
allowMultiInstanceCheck.addEventListener('change', saveAllowMultiInstance);
}
if (launcherHwAccelCheck) { if (launcherHwAccelCheck) {
launcherHwAccelCheck.addEventListener('change', saveLauncherHwAccel); launcherHwAccelCheck.addEventListener('change', saveLauncherHwAccel);
} }
@@ -415,6 +421,30 @@ async function loadCloseLauncher() {
} }
} }
async function saveAllowMultiInstance() {
try {
if (window.electronAPI && window.electronAPI.saveAllowMultiInstance && allowMultiInstanceCheck) {
const enabled = allowMultiInstanceCheck.checked;
await window.electronAPI.saveAllowMultiInstance(enabled);
}
} catch (error) {
console.error('Error saving multi-instance setting:', error);
}
}
async function loadAllowMultiInstance() {
try {
if (window.electronAPI && window.electronAPI.loadAllowMultiInstance) {
const enabled = await window.electronAPI.loadAllowMultiInstance();
if (allowMultiInstanceCheck) {
allowMultiInstanceCheck.checked = enabled;
}
}
} catch (error) {
console.error('Error loading multi-instance setting:', error);
}
}
async function saveLauncherHwAccel() { async function saveLauncherHwAccel() {
try { try {
if (window.electronAPI && window.electronAPI.saveLauncherHardwareAcceleration && launcherHwAccelCheck) { if (window.electronAPI && window.electronAPI.saveLauncherHardwareAcceleration && launcherHwAccelCheck) {
@@ -485,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);
@@ -587,6 +618,7 @@ async function loadAllSettings() {
await loadCurrentUuid(); await loadCurrentUuid();
await loadDiscordRPC(); await loadDiscordRPC();
await loadCloseLauncher(); await loadCloseLauncher();
await loadAllowMultiInstance();
await loadLauncherHwAccel(); await loadLauncherHwAccel();
await loadGpuPreference(); await loadGpuPreference();
await loadVersionBranch(); await loadVersionBranch();
@@ -715,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');
} }
@@ -881,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';
@@ -900,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';
@@ -910,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.';
@@ -985,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;
@@ -996,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');
} }
@@ -1074,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)
@@ -1093,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;
}
// Check if password-protected
let isProtected = false;
if (uuid && window.electronAPI && window.electronAPI.checkPasswordStatus) {
try {
const pwStatus = await window.electronAPI.checkPasswordStatus(uuid);
isProtected = pwStatus && pwStatus.hasPassword;
} catch (e) {
console.log('[Identity] Password status check failed:', e.message);
}
}
if (isProtected) {
// Password-protected identity — show warning with password input
showPasswordProtectedDeleteDialog(username, uuid);
} else {
// Normal identity — simple confirm
const message = `Are you sure you want to delete the identity "${username}"? This action cannot be undone.`;
showCustomConfirm( showCustomConfirm(
message, message,
title, 'Delete Identity',
async () => { async () => { await performDeleteUuid(username); },
await performDeleteUuid(username);
},
null, null,
confirmBtn, 'Delete',
cancelBtn '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

@@ -140,6 +140,8 @@
"closeLauncher": "سلوك المشغل", "closeLauncher": "سلوك المشغل",
"closeOnStart": "إغلاق المشغل عند بدء اللعبة", "closeOnStart": "إغلاق المشغل عند بدء اللعبة",
"closeOnStartDescription": "إغلاق المشغل تلقائياً بعد تشغيل Hytale", "closeOnStartDescription": "إغلاق المشغل تلقائياً بعد تشغيل Hytale",
"allowMultiInstance": "Allow multiple game instances",
"allowMultiInstanceDescription": "Allow running multiple game clients at the same time (useful for mod development)",
"hwAccel": "تسريع الأجهزة (Hardware Acceleration)", "hwAccel": "تسريع الأجهزة (Hardware Acceleration)",
"hwAccelDescription": "تفعيل تسريع الأجهزة للمشغل", "hwAccelDescription": "تفعيل تسريع الأجهزة للمشغل",
"gameBranch": "فرع اللعبة", "gameBranch": "فرع اللعبة",

View File

@@ -140,6 +140,8 @@
"closeLauncher": "Launcher-Verhalten", "closeLauncher": "Launcher-Verhalten",
"closeOnStart": "Launcher beim Spielstart schließen", "closeOnStart": "Launcher beim Spielstart schließen",
"closeOnStartDescription": "Schließe den Launcher automatisch, nachdem Hytale gestartet wurde", "closeOnStartDescription": "Schließe den Launcher automatisch, nachdem Hytale gestartet wurde",
"allowMultiInstance": "Allow multiple game instances",
"allowMultiInstanceDescription": "Allow running multiple game clients at the same time (useful for mod development)",
"hwAccel": "Hardware-Beschleunigung", "hwAccel": "Hardware-Beschleunigung",
"hwAccelDescription": "Hardware-Beschleunigung für den Launcher aktivieren", "hwAccelDescription": "Hardware-Beschleunigung für den Launcher aktivieren",
"gameBranch": "Spiel-Branch", "gameBranch": "Spiel-Branch",

View File

@@ -140,6 +140,8 @@
"closeLauncher": "Launcher Behavior", "closeLauncher": "Launcher Behavior",
"closeOnStart": "Close Launcher on game start", "closeOnStart": "Close Launcher on game start",
"closeOnStartDescription": "Automatically close the launcher after Hytale has launched", "closeOnStartDescription": "Automatically close the launcher after Hytale has launched",
"allowMultiInstance": "Allow multiple game instances",
"allowMultiInstanceDescription": "Allow running multiple game clients at the same time (useful for mod development)",
"hwAccel": "Hardware Acceleration", "hwAccel": "Hardware Acceleration",
"hwAccelDescription": "Enable hardware acceleration for the launcher", "hwAccelDescription": "Enable hardware acceleration for the launcher",
"gameBranch": "Game Branch", "gameBranch": "Game Branch",

View File

@@ -140,6 +140,8 @@
"closeLauncher": "Comportamiento del Launcher", "closeLauncher": "Comportamiento del Launcher",
"closeOnStart": "Cerrar Launcher al iniciar el juego", "closeOnStart": "Cerrar Launcher al iniciar el juego",
"closeOnStartDescription": "Cierra automáticamente el launcher después de que Hytale se haya iniciado", "closeOnStartDescription": "Cierra automáticamente el launcher después de que Hytale se haya iniciado",
"allowMultiInstance": "Allow multiple game instances",
"allowMultiInstanceDescription": "Allow running multiple game clients at the same time (useful for mod development)",
"hwAccel": "Aceleración por Hardware", "hwAccel": "Aceleración por Hardware",
"hwAccelDescription": "Habilitar aceleración por hardware para el launcher", "hwAccelDescription": "Habilitar aceleración por hardware para el launcher",
"gameBranch": "Rama del Juego", "gameBranch": "Rama del Juego",

View File

@@ -140,6 +140,8 @@
"closeLauncher": "Comportement du Launcher", "closeLauncher": "Comportement du Launcher",
"closeOnStart": "Fermer le Launcher au démarrage du jeu", "closeOnStart": "Fermer le Launcher au démarrage du jeu",
"closeOnStartDescription": "Fermer automatiquement le launcher après le lancement d'Hytale", "closeOnStartDescription": "Fermer automatiquement le launcher après le lancement d'Hytale",
"allowMultiInstance": "Allow multiple game instances",
"allowMultiInstanceDescription": "Allow running multiple game clients at the same time (useful for mod development)",
"hwAccel": "Accélération Matérielle", "hwAccel": "Accélération Matérielle",
"hwAccelDescription": "Activer l'accélération matérielle pour le launcher", "hwAccelDescription": "Activer l'accélération matérielle pour le launcher",
"gameBranch": "Branche du Jeu", "gameBranch": "Branche du Jeu",

View File

@@ -140,6 +140,8 @@
"closeLauncher": "Perilaku Launcher", "closeLauncher": "Perilaku Launcher",
"closeOnStart": "Tutup launcher saat game dimulai", "closeOnStart": "Tutup launcher saat game dimulai",
"closeOnStartDescription": "Tutup launcher secara otomatis setelah Hytale diluncurkan", "closeOnStartDescription": "Tutup launcher secara otomatis setelah Hytale diluncurkan",
"allowMultiInstance": "Allow multiple game instances",
"allowMultiInstanceDescription": "Allow running multiple game clients at the same time (useful for mod development)",
"hwAccel": "Akselerasi Perangkat Keras", "hwAccel": "Akselerasi Perangkat Keras",
"hwAccelDescription": "Aktifkan akselerasi perangkat keras untuk launcher`", "hwAccelDescription": "Aktifkan akselerasi perangkat keras untuk launcher`",
"gameBranch": "Cabang Game", "gameBranch": "Cabang Game",

View File

@@ -140,6 +140,8 @@
"closeLauncher": "Zachowanie Launchera", "closeLauncher": "Zachowanie Launchera",
"closeOnStart": "Zamknij Launcher przy starcie gry", "closeOnStart": "Zamknij Launcher przy starcie gry",
"closeOnStartDescription": "Automatycznie zamknij launcher po uruchomieniu Hytale", "closeOnStartDescription": "Automatycznie zamknij launcher po uruchomieniu Hytale",
"allowMultiInstance": "Allow multiple game instances",
"allowMultiInstanceDescription": "Allow running multiple game clients at the same time (useful for mod development)",
"hwAccel": "Przyspieszenie Sprzętowe", "hwAccel": "Przyspieszenie Sprzętowe",
"hwAccelDescription": "Włącz przyspieszenie sprzętowe dla launchera", "hwAccelDescription": "Włącz przyspieszenie sprzętowe dla launchera",
"gameBranch": "Gałąź Gry", "gameBranch": "Gałąź Gry",

View File

@@ -140,6 +140,8 @@
"closeLauncher": "Comportamento do Lançador", "closeLauncher": "Comportamento do Lançador",
"closeOnStart": "Fechar Lançador ao iniciar o jogo", "closeOnStart": "Fechar Lançador ao iniciar o jogo",
"closeOnStartDescription": "Fechar automaticamente o lançador após o Hytale ter sido iniciado", "closeOnStartDescription": "Fechar automaticamente o lançador após o Hytale ter sido iniciado",
"allowMultiInstance": "Allow multiple game instances",
"allowMultiInstanceDescription": "Allow running multiple game clients at the same time (useful for mod development)",
"hwAccel": "Aceleração de Hardware", "hwAccel": "Aceleração de Hardware",
"hwAccelDescription": "Ativar aceleração de hardware para o lançador", "hwAccelDescription": "Ativar aceleração de hardware para o lançador",
"gameBranch": "Versão do Jogo", "gameBranch": "Versão do Jogo",

View File

@@ -140,6 +140,8 @@
"closeLauncher": "Поведение лаунчера", "closeLauncher": "Поведение лаунчера",
"closeOnStart": "Закрыть лаунчер при старте игры", "closeOnStart": "Закрыть лаунчер при старте игры",
"closeOnStartDescription": "Автоматически закрыть лаунчер после запуска Hytale", "closeOnStartDescription": "Автоматически закрыть лаунчер после запуска Hytale",
"allowMultiInstance": "Несколько копий игры",
"allowMultiInstanceDescription": "Разрешить запуск нескольких клиентов одновременно (полезно для разработки модов)",
"hwAccel": "Аппаратное ускорение", "hwAccel": "Аппаратное ускорение",
"hwAccelDescription": "Включить аппаратное ускорение для лаунчера", "hwAccelDescription": "Включить аппаратное ускорение для лаунчера",
"gameBranch": "Ветка игры", "gameBranch": "Ветка игры",

View File

@@ -140,6 +140,8 @@
"closeLauncher": "Launcher-beteende", "closeLauncher": "Launcher-beteende",
"closeOnStart": "Stäng launcher vid spelstart", "closeOnStart": "Stäng launcher vid spelstart",
"closeOnStartDescription": "Stäng automatiskt launcher efter att Hytale har startats", "closeOnStartDescription": "Stäng automatiskt launcher efter att Hytale har startats",
"allowMultiInstance": "Allow multiple game instances",
"allowMultiInstanceDescription": "Allow running multiple game clients at the same time (useful for mod development)",
"hwAccel": "Hårdvaruacceleration", "hwAccel": "Hårdvaruacceleration",
"hwAccelDescription": "Aktivera hårdvaruacceleration för launchern", "hwAccelDescription": "Aktivera hårdvaruacceleration för launchern",
"gameBranch": "Spelgren", "gameBranch": "Spelgren",

View File

@@ -140,6 +140,8 @@
"closeLauncher": "Başlatıcı Davranışı", "closeLauncher": "Başlatıcı Davranışı",
"closeOnStart": "Oyun başlatıldığında Başlatıcıyı Kapat", "closeOnStart": "Oyun başlatıldığında Başlatıcıyı Kapat",
"closeOnStartDescription": "Hytale başlatıldıktan sonra başlatıcıyı otomatik olarak kapatın", "closeOnStartDescription": "Hytale başlatıldıktan sonra başlatıcıyı otomatik olarak kapatın",
"allowMultiInstance": "Allow multiple game instances",
"allowMultiInstanceDescription": "Allow running multiple game clients at the same time (useful for mod development)",
"hwAccel": "Donanım Hızlandırma", "hwAccel": "Donanım Hızlandırma",
"hwAccelDescription": "Başlatıcı için donanım hızlandırmasını etkinleştir", "hwAccelDescription": "Başlatıcı için donanım hızlandırmasını etkinleştir",
"gameBranch": "Oyun Dalı", "gameBranch": "Oyun Dalı",

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 });
} }
// ============================================================================= // =============================================================================
@@ -708,6 +722,15 @@ function loadLauncherHardwareAcceleration() {
return config.launcherHardwareAcceleration !== undefined ? config.launcherHardwareAcceleration : true; return config.launcherHardwareAcceleration !== undefined ? config.launcherHardwareAcceleration : true;
} }
function saveAllowMultiInstance(enabled) {
saveConfig({ allowMultiInstance: !!enabled });
}
function loadAllowMultiInstance() {
const config = loadConfig();
return config.allowMultiInstance !== undefined ? config.allowMultiInstance : false;
}
// ============================================================================= // =============================================================================
// MODS MANAGEMENT // MODS MANAGEMENT
// ============================================================================= // =============================================================================
@@ -1105,6 +1128,8 @@ module.exports = {
loadCloseLauncherOnStart, loadCloseLauncherOnStart,
saveLauncherHardwareAcceleration, saveLauncherHardwareAcceleration,
loadLauncherHardwareAcceleration, loadLauncherHardwareAcceleration,
saveAllowMultiInstance,
loadAllowMultiInstance,
// Mods // Mods
saveModsToConfig, saveModsToConfig,

View File

@@ -20,6 +20,8 @@ const {
saveLauncherHardwareAcceleration, saveLauncherHardwareAcceleration,
loadLauncherHardwareAcceleration, loadLauncherHardwareAcceleration,
saveAllowMultiInstance,
loadAllowMultiInstance,
loadConfig, loadConfig,
saveConfig, saveConfig,
@@ -151,6 +153,10 @@ module.exports = {
saveLauncherHardwareAcceleration, saveLauncherHardwareAcceleration,
loadLauncherHardwareAcceleration, loadLauncherHardwareAcceleration,
// Multi-instance functions
saveAllowMultiInstance,
loadAllowMultiInstance,
// Config functions // Config functions
loadConfig, loadConfig,
saveConfig, saveConfig,

View File

@@ -30,6 +30,7 @@ const { syncModsForCurrentProfile } = require('./modManager');
const { getUserDataPath } = require('../utils/userDataMigration'); const { getUserDataPath } = require('../utils/userDataMigration');
const { syncServerList } = require('../utils/serverListSync'); const { syncServerList } = require('../utils/serverListSync');
const { killGameProcesses } = require('./gameManager'); const { killGameProcesses } = require('./gameManager');
const { loadAllowMultiInstance } = require('../core/config');
// Client patcher for custom auth server (sanasol.ws) // Client patcher for custom auth server (sanasol.ws)
let clientPatcher = null; let clientPatcher = null;
@@ -42,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}`);
} }
@@ -76,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();
@@ -98,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);
@@ -146,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
@@ -255,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
@@ -464,8 +499,10 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
} }
// Kill any stalled game processes from a previous launch to prevent file locks // Kill any stalled game processes from a previous launch to prevent file locks
// and "game already running" issues // and "game already running" issues (skip if multi-instance mode is enabled)
if (!loadAllowMultiInstance()) {
await killGameProcesses(); await killGameProcesses();
}
// Remove AOT cache: generated by official Hytale JRE, incompatible with F2P JRE. // Remove AOT cache: generated by official Hytale JRE, incompatible with F2P JRE.
// Client adds -XX:AOTCache when this file exists, causing classloading failures. // Client adds -XX:AOTCache when this file exists, causing classloading failures.
@@ -575,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
@@ -648,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) {
@@ -662,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

@@ -106,6 +106,23 @@ function getBundledJavaPath(jreDir = JRE_DIR) {
} }
} }
// Fallback: check for nested JRE directory (e.g. jdk-25.0.2+10-jre/bin/java)
// This happens when flattenJREDir fails due to EPERM/EACCES on Windows
try {
if (fs.existsSync(jreDir)) {
const entries = fs.readdirSync(jreDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'bin' && entry.name !== 'lib') {
const nestedCandidate = path.join(jreDir, entry.name, 'bin', JAVA_EXECUTABLE);
if (fs.existsSync(nestedCandidate)) {
console.log(`[JRE] Using nested Java path: ${nestedCandidate}`);
return nestedCandidate;
}
}
}
}
} catch (_) { /* ignore */ }
return null; return null;
} }
@@ -420,12 +437,48 @@ function flattenJREDir(jreLatest) {
for (const file of files) { for (const file of files) {
const oldPath = path.join(nested, file.name); const oldPath = path.join(nested, file.name);
const newPath = path.join(jreLatest, file.name); const newPath = path.join(jreLatest, file.name);
try {
fs.renameSync(oldPath, newPath); fs.renameSync(oldPath, newPath);
} catch (renameErr) {
if (renameErr.code === 'EPERM' || renameErr.code === 'EACCES' || renameErr.code === 'EBUSY') {
console.log(`[JRE] Rename failed for ${file.name} (${renameErr.code}), using copy fallback`);
copyRecursiveSync(oldPath, newPath);
} else {
throw renameErr;
}
}
} }
try {
fs.rmSync(nested, { recursive: true, force: true }); fs.rmSync(nested, { recursive: true, force: true });
} catch (rmErr) {
console.log('[JRE] Could not remove nested JRE dir (non-critical):', rmErr.message);
}
} catch (err) { } catch (err) {
console.log('Notice: could not restructure Java directory:', err.message); console.error('[JRE] Failed to restructure Java directory:', err.message);
// Last resort: check if java exists in a nested subdir and skip flatten
try {
const entries = fs.readdirSync(jreLatest, { withFileTypes: true });
const nestedDir = entries.find(e => e.isDirectory() && e.name !== 'bin' && e.name !== 'lib');
if (nestedDir) {
const nestedBin = path.join(jreLatest, nestedDir.name, 'bin', process.platform === 'win32' ? 'java.exe' : 'java');
if (fs.existsSync(nestedBin)) {
console.log(`[JRE] Java found in nested dir: ${nestedDir.name}, leaving structure as-is`);
}
}
} catch (_) { /* ignore */ }
}
}
function copyRecursiveSync(src, dest) {
const stat = fs.statSync(src);
if (stat.isDirectory()) {
fs.mkdirSync(dest, { recursive: true });
for (const child of fs.readdirSync(src)) {
copyRecursiveSync(path.join(src, child), path.join(dest, child));
}
} else {
fs.copyFileSync(src, dest);
} }
} }

View File

@@ -1,10 +1,10 @@
# Singleplayer Server Crash: fastutil ClassNotFoundException # Singleplayer Server Crash: fastutil ClassNotFoundException
## Status: Open (user-specific, Feb 24 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
@@ -16,98 +16,75 @@ Caused by: java.lang.ClassNotFoundException: it.unimi.dsi.fastutil.objects.Objec
Server exits with code 1. Multiplayer works fine for the same user. Server exits with code 1. Multiplayer works fine for the same user.
## Affected User ## Affected Users
- Discord: ヅ𝚃 JAYED ! 1. **ヅ𝚃 JAYED !** (Feb 24) — Windows x86_64, had AOT cache errors before fastutil crash
- Platform: Windows (standard x86_64, NOT ARM) 2. **Asentrix** (Feb 27) — Windows x86_64 (NT 10.0.26200.0), RTX 4060, Launcher v2.4.4, NO AOT cache errors
- Reproduces 100% on singleplayer, every attempt 3. **7645754** (Feb 28) — Standalone server on localhost, **FIXED by updating HytaleServer.jar**
- Other users (including macOS/Linux) are NOT affected
## What Works - Reproduces 100% on singleplayer, every attempt (users 1-2)
- Multiplayer works fine for users 1-2
- macOS/Linux users are NOT affected
- Java wrapper correctly strips `-XX:+UseCompactObjectHeaders` ## Ruled Out (confirmed via debug builds)
- 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
## Root Cause Analysis | 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. |
`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. ## Key Finding: Outdated HytaleServer.jar (Feb 28)
### Ruled Out 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.
- **Wrapper issue**: Wrapper is working correctly (confirmed in logs) 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.
- **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 Causes (user-specific) ## What We Know
1. **Antivirus interference** — Windows Defender or third-party AV blocking Java from reading classes out of JAR files, especially with `-javaagent` active - `fastutil` is bundled inside `HytaleServer.jar` (fat/shaded JAR)
2. **Corrupted/incompatible JRE** — bundled JRE might be broken on their system - JVM's `BuiltinClassLoader` cannot find `it.unimi.dsi.fastutil.objects.ObjectArrayList` despite it being in the JAR
3. **File locking** — another process holding HytaleServer.jar open - 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
## Debugging Steps (ask user) ## Fix for Users
1. **Does official Hytale singleplayer work?** (without F2P launcher) ### F2P Launcher users (Asentrix, JAYED)
- Yes → something about our launch setup 1. **Delete the entire game folder**: `%LOCALAPPDATA%\HytaleF2P\release\package\game\`
- No → their system/JRE issue 2. Relaunch — launcher will re-download everything fresh
3. NOT just "repair" — full delete to ensure no stale files remain
2. **Check antivirus** — add game directory to Windows Defender exclusions: ### Standalone server users
- Settings → Windows Security → Virus & threat protection → Exclusions 1. Download fresh `HytaleServer.jar` from current game version
- Add their HytaleF2P install folder 2. Replace the old JAR file
3. **Verify fastutil is in the JAR**: ## Update History
```cmd
jar tf "D:\path\to\Server\HytaleServer.jar" | findstr fastutil
```
- If output shows fastutil classes → JAR is fine, classloader issue
- If no output → JAR is incomplete/corrupt (different from other users)
4. **Try without DualAuth agent** — rename `dualauth-agent.jar` in Server/ folder, retry singleplayer ### Feb 24: First report (JAYED)
- If works → agent's classloader manipulation breaks fastutil on their setup User reported singleplayer crash. Initial investigation found AOT cache errors + fastutil ClassNotFoundException. Stripping `-XX:+UseCompactObjectHeaders` did not help.
- If still fails → unrelated to agent
5. **Check JRE version** — have them run: ### Feb 27: Second report (Asentrix), extensive debugging
```cmd - Asentrix hit same crash, no AOT errors — ruled out AOT as root cause
"D:\path\to\jre\latest\bin\java.exe" -version - 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
## Update (Feb 24): `-XX:+UseCompactObjectHeaders` stripping removed from defaults ### Feb 28: Third user (7645754) — FIXED by replacing HytaleServer.jar
- Standalone server user had same crash on localhost, VPS worked fine
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`. - **Fixed by updating `HytaleServer.jar` to match VPS version**
- Root cause likely: outdated JAR incompatible with current/updated JRE
## Using the Java Wrapper to Strip JVM Flags - For F2P launcher users: need to delete game folder and force fresh re-download
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" }
]
}
}
```
## 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 report: `2026.02.19-1a311a592` - Game version at time of reports: `2026.02.19-1a311a592`
- Debug tags: `debug-xshare-off`, `debug-no-agent`
- Log submission IDs: `c88e7b71` (Asentrix initial), `0445e4dc` (xshare test), `748dceeb` (no-agent test)

184
main.js
View File

@@ -3,7 +3,7 @@ require('dotenv').config({ path: path.join(__dirname, '.env') });
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron'); const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
const { autoUpdater } = require('electron-updater'); const { autoUpdater } = require('electron-updater');
const fs = require('fs'); const fs = require('fs');
const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, saveDiscordRPC, loadDiscordRPC, saveLanguage, loadLanguage, saveCloseLauncherOnStart, loadCloseLauncherOnStart, saveLauncherHardwareAcceleration, loadLauncherHardwareAcceleration, isGameInstalled, uninstallGame, repairGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched, loadConfig, saveConfig, checkLaunchReady } = require('./backend/launcher'); const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, saveDiscordRPC, loadDiscordRPC, saveLanguage, loadLanguage, saveCloseLauncherOnStart, loadCloseLauncherOnStart, saveLauncherHardwareAcceleration, loadLauncherHardwareAcceleration, saveAllowMultiInstance, loadAllowMultiInstance, isGameInstalled, uninstallGame, repairGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched, loadConfig, saveConfig, checkLaunchReady } = require('./backend/launcher');
const { retryPWRDownload } = require('./backend/managers/gameManager'); const { retryPWRDownload } = require('./backend/managers/gameManager');
const { migrateUserDataToCentralized } = require('./backend/utils/userDataMigration'); const { migrateUserDataToCentralized } = require('./backend/utils/userDataMigration');
@@ -23,8 +23,9 @@ const profileManager = require('./backend/managers/profileManager');
logger.interceptConsole(); logger.interceptConsole();
// Single instance lock // Single instance lock (skip if multi-instance mode is enabled)
const gotTheLock = app.requestSingleInstanceLock(); const multiInstanceEnabled = loadAllowMultiInstance();
const gotTheLock = multiInstanceEnabled || app.requestSingleInstanceLock();
if (!gotTheLock) { if (!gotTheLock) {
console.log('Another instance is already running. Quitting...'); console.log('Another instance is already running. Quitting...');
@@ -529,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();
@@ -553,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:`);
@@ -740,6 +818,15 @@ ipcMain.handle('load-close-launcher', () => {
return loadCloseLauncherOnStart(); return loadCloseLauncherOnStart();
}); });
ipcMain.handle('save-allow-multi-instance', (event, enabled) => {
saveAllowMultiInstance(enabled);
return { success: true };
});
ipcMain.handle('load-allow-multi-instance', () => {
return loadAllowMultiInstance();
});
ipcMain.handle('save-launcher-hw-accel', (event, enabled) => { ipcMain.handle('save-launcher-hw-accel', (event, enabled) => {
saveLauncherHardwareAcceleration(enabled); saveLauncherHardwareAcceleration(enabled);
return { success: true }; return { success: true };
@@ -1342,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);
@@ -1381,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.4", "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'),
@@ -20,6 +21,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
loadLanguage: () => ipcRenderer.invoke('load-language'), loadLanguage: () => ipcRenderer.invoke('load-language'),
saveCloseLauncher: (enabled) => ipcRenderer.invoke('save-close-launcher', enabled), saveCloseLauncher: (enabled) => ipcRenderer.invoke('save-close-launcher', enabled),
loadCloseLauncher: () => ipcRenderer.invoke('load-close-launcher'), loadCloseLauncher: () => ipcRenderer.invoke('load-close-launcher'),
saveAllowMultiInstance: (enabled) => ipcRenderer.invoke('save-allow-multi-instance', enabled),
loadAllowMultiInstance: () => ipcRenderer.invoke('load-allow-multi-instance'),
loadConfig: () => ipcRenderer.invoke('load-config'), loadConfig: () => ipcRenderer.invoke('load-config'),
saveConfig: (configUpdate) => ipcRenderer.invoke('save-config', configUpdate), saveConfig: (configUpdate) => ipcRenderer.invoke('save-config', configUpdate),
@@ -100,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),

View File

@@ -1,17 +0,0 @@
## v2.4.0
### New Features
- **Send Logs** — One-click button to submit launcher & game logs to support. Collects launcher logs, game client logs, and config snapshot into a ZIP, uploads to server, and returns an 8-character ID to share with support ([320ca54](https://git.sanhost.net/sanasol/hytale-f2p/commit/320ca547585c67d0773dba262612db5026378f52), [19c8991](https://git.sanhost.net/sanasol/hytale-f2p/commit/19c8991a44641ebbf44eec73a0ecd9db05241c49))
- **Arabic (ar-SA) locale** with full RTL support (community contribution by @Yugurten) ([30929ee](https://git.sanhost.net/sanasol/hytale-f2p/commit/30929ee0da5a9c64e65869d6157bd705db3b80f0))
- **One-click dedicated server scripts** for self-hosting ([552ec42](https://git.sanhost.net/sanasol/hytale-f2p/commit/552ec42d6c7e1e7d1a2803d284019ccae963f41e))
### Bug Fixes
- Fix Intel Arc iGPU (Meteor Lake/Lunar Lake) on PCI bus 00 being misdetected as discrete GPU on dual-GPU Linux systems ([19c8991](https://git.sanhost.net/sanasol/hytale-f2p/commit/19c8991a44641ebbf44eec73a0ecd9db05241c49))
- Fix stalled game processes blocking launcher operations — automatic process cleanup on repair and relaunch ([e14d56e](https://git.sanhost.net/sanasol/hytale-f2p/commit/e14d56ef4846423c1fd172d88334cb76193ee741))
- Fix AOT cache crashes — stale cache cleared before game launch ([e14d56e](https://git.sanhost.net/sanasol/hytale-f2p/commit/e14d56ef4846423c1fd172d88334cb76193ee741))
- Fix Arabic RTL CSS syntax ([fb90277](https://git.sanhost.net/sanasol/hytale-f2p/commit/fb90277be9cf5f0b8a90195a7d089273b6be082b))
### Other
- Updated README with Forgejo URLs and server setup video ([a649bf1](https://git.sanhost.net/sanasol/hytale-f2p/commit/a649bf1fcc7cbb2cd0d9aa0160b07828a144b9dd), [66faa1b](https://git.sanhost.net/sanasol/hytale-f2p/commit/66faa1bb1e39575fecb462310af338d13b1cb183))
**Full changelog**: [v2.3.8...v2.4.0](https://git.sanhost.net/sanasol/hytale-f2p/compare/v2.3.8...v2.4.0)