Compare commits

..

13 Commits

Author SHA1 Message Date
sanasol
0d15659dc0 v2.4.1: Replace raw wrapper script editor with structured config UI
Replace the raw textarea script editor with a structured form for Java
wrapper configuration. Users now manage two lists (JVM flags to strip,
args to inject with server/always condition) instead of editing bash/batch
scripts directly. Scripts are generated at launch time from the structured
config. Includes collapsible script preview for power users.
2026-02-24 16:53:19 +01:00
sanasol
19c8991a44 v2.4.0: Add Send Logs, fix Intel Arc iGPU misdetection
- Add Send Logs button for one-click log submission to support
- Fix Intel Arc Graphics (Meteor Lake/Lunar Lake) on bus 00 being
  classified as discrete GPU instead of integrated
2026-02-24 13:57:10 +01:00
sanasol
320ca54758 Add Send Logs button for one-click log submission to support
Collects launcher logs, game client logs, and config snapshot into a ZIP
file and uploads to auth server. Shows submission ID for sharing with
support. Includes i18n for all 11 locales.
2026-02-24 13:50:23 +01:00
sanasol
e14d56ef48 v2.3.9: Fix stalled processes and AOT cache crash
- Auto-kill stalled HytaleClient/java processes before launch and repair
  (cross-platform: Windows taskkill+PowerShell, macOS/Linux pkill)
- Remove HytaleServer.aot before launch to prevent incompatible AOT cache
  causing fastutil ClassNotFoundException on singleplayer
- safeRemoveDirectory auto-kills processes on EPERM/EBUSY before retry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:52:07 +01:00
sanasol
a649bf1fcc Update README: replace outdated GitHub URL with Forgejo URL for start.sh script 2026-02-23 03:12:39 +01:00
sanasol
66faa1bb1e Update README files with free hosted server instructions
Added detailed steps to set up a free Hytale server via play.hosting in both README files. Updated outdated URLs for the F2P Launcher to the new repository location. Improved clarity in self-hosted setup sections.
2026-02-23 03:11:02 +01:00
sanasol
a63e026700 Server setup video 2026-02-23 01:02:16 +01:00
sanasol
552ec42d6c Add one-click dedicated server scripts for Hytale F2P
- server/start.sh: Linux/macOS starter with auto-download, auto-update,
  F2P launcher detection (game files + bundled JRE), and token fetch
- server/start.bat: Windows equivalent using PowerShell for JSON/UUID
- server/README.md: Beginner-friendly guide with playit.gg networking
- Remove obsolete server_scripts_2.zip

Scripts auto-detect F2P launcher install (including custom installPath
from config.json), copy game files locally if available, download
missing files (HytaleServer.jar, Assets.zip, dualauth-agent.jar),
and check for updates on every launch via ETag/GitHub releases API.
2026-02-23 00:55:38 +01:00
sanasol
fb90277be9 Fix Arabic RTL support: correct locale code and CSS syntax
- Rename ar-AR to ar-SA (valid BCP 47 code for Saudi Arabia)
- Fix missing dot in CSS selector: .news-section .news-header
- Add trailing newline to ar-SA.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:00:08 +01:00
sanasol
27c220a757 Merge pull request 'Arabic and RTL support added' (#1) from Yugurten/hytale-f2p:develop into develop 2026-02-22 21:58:25 +00:00
Finix
30929ee0da Arabic and RTL support added 2026-02-22 19:25:01 +00:00
sanasol
44834e7d12 Bump version to 2.3.8
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:47:52 +01:00
sanasol
cb7f7e51bf v2.3.8: auto-update agent from GitHub releases, add dl1 mirror fallback
- Agent auto-update: check GitHub releases API for new versions, download
  only when update available, track version in .version file
- Add dl1.htdwnldsan.top as backup-2 mirror in patches config sources
- Add dl1.htdwnldsan.top as primary non-Cloudflare mirror
- Graceful fallback: use existing agent if update check or download fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:31:28 +01:00
31 changed files with 3011 additions and 47 deletions

View File

@@ -8,9 +8,10 @@
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link <link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Noto+Sans+Arabic:wght@300;400;500;600;700&display=swap"
rel="stylesheet"> rel="stylesheet">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="style-RTL.css">
</head> </head>
<body class="bg-black text-white overflow-hidden font-sans select-none" tabindex="-1"> <body class="bg-black text-white overflow-hidden font-sans select-none" tabindex="-1">
@@ -429,6 +430,68 @@
</div> </div>
</div> </div>
</div> </div>
<div class="settings-section">
<h3 class="settings-section-title">
<i class="fas fa-scroll"></i>
<span data-i18n="settings.wrapperConfig">Java Wrapper Configuration</span>
</h3>
<p class="settings-hint" style="margin-bottom: 12px;">
<i class="fas fa-info-circle"></i>
<span data-i18n="settings.wrapperConfigHint">Configure how the Java wrapper handles JVM flags and arguments at launch time.</span>
</p>
<!-- Strip Flags -->
<label class="settings-label" style="margin-bottom: 6px;">
<span data-i18n="settings.wrapperStripFlags">JVM Flags to Remove</span>
</label>
<div id="wrapperStripFlagsList" class="wrapper-items-list"></div>
<div style="display: flex; gap: 6px; margin-top: 6px;">
<input type="text" id="wrapperAddFlagInput" class="settings-input" style="flex:1;"
data-i18n-placeholder="settings.wrapperAddFlagPlaceholder" placeholder="e.g. -XX:+SomeFlag" spellcheck="false">
<button id="wrapperAddFlagBtn" class="settings-browse-btn">
<i class="fas fa-plus"></i>
<span data-i18n="settings.wrapperAdd">Add</span>
</button>
</div>
<!-- Inject Args -->
<label class="settings-label" style="margin-top: 16px; margin-bottom: 6px;">
<span data-i18n="settings.wrapperInjectArgs">Arguments to Inject</span>
</label>
<div id="wrapperInjectArgsList" class="wrapper-items-list"></div>
<div style="display: flex; gap: 6px; margin-top: 6px;">
<input type="text" id="wrapperAddArgInput" class="settings-input" style="flex:1;"
data-i18n-placeholder="settings.wrapperAddArgPlaceholder" placeholder="e.g. --some-flag" spellcheck="false">
<select id="wrapperAddArgCondition" class="wrapper-condition-select">
<option value="server" data-i18n="settings.wrapperConditionServer">Server Only</option>
<option value="always" data-i18n="settings.wrapperConditionAlways">Always</option>
</select>
<button id="wrapperAddArgBtn" class="settings-browse-btn">
<i class="fas fa-plus"></i>
<span data-i18n="settings.wrapperAdd">Add</span>
</button>
</div>
<!-- Restore Defaults -->
<div style="margin-top: 12px;">
<button id="wrapperRestoreDefaultsBtn" class="settings-browse-btn" style="background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.3);">
<i class="fas fa-undo"></i>
<span data-i18n="settings.wrapperRestoreDefaults">Restore Defaults</span>
</button>
</div>
<!-- Script Preview (collapsible) -->
<div style="margin-top: 12px;">
<button id="wrapperPreviewToggle" class="wrapper-preview-toggle">
<i class="fas fa-chevron-right" id="wrapperPreviewChevron"></i>
<span data-i18n="settings.wrapperAdvancedPreview">Advanced: Script Preview</span>
</button>
<div id="wrapperPreviewContainer" style="display: none; margin-top: 8px;">
<pre id="wrapperPreviewContent" class="wrapper-preview-content"></pre>
</div>
</div>
</div>
</div> </div>
<div class="settings-column"> <div class="settings-column">
@@ -574,6 +637,9 @@
<i class="fas fa-folder-open"></i> <span data-i18n="settings.logsFolder">Open <i class="fas fa-folder-open"></i> <span data-i18n="settings.logsFolder">Open
Folder</span> Folder</span>
</button> </button>
<button class="logs-action-btn logs-send-btn" id="sendLogsBtn" onclick="sendLogs()">
<i class="fas fa-paper-plane"></i> <span data-i18n="settings.logsSend">Send Logs</span>
</button>
</div> </div>
</div> </div>
<div id="logsTerminal" class="logs-terminal"> <div id="logsTerminal" class="logs-terminal">

View File

@@ -12,9 +12,18 @@ const i18n = (() => {
{ code: 'ru-RU', name: 'Russian (Russia)' }, { code: 'ru-RU', name: 'Russian (Russia)' },
{ code: 'sv-SE', name: 'Swedish (Sweden)' }, { code: 'sv-SE', name: 'Swedish (Sweden)' },
{ code: 'tr-TR', name: 'Turkish (Turkey)' }, { code: 'tr-TR', name: 'Turkish (Turkey)' },
{ code: 'id-ID', name: 'Indonesian (Indonesia)' } { code: 'id-ID', name: 'Indonesian (Indonesia)' },
{ code: 'ar-SA', name: 'Arabic (Saudi Arabia)' }
]; ];
// RTL languages
const rtlLanguages = ['ar-SA'];
// Check if current language is RTL
function isRTL() {
return rtlLanguages.includes(currentLang);
}
// Load single language file // Load single language file
async function loadLanguage(lang) { async function loadLanguage(lang) {
if (translations[lang]) return true; if (translations[lang]) return true;
@@ -73,6 +82,24 @@ const i18n = (() => {
const key = el.getAttribute('data-i18n-title'); const key = el.getAttribute('data-i18n-title');
el.title = t(key); el.title = t(key);
}); });
// Update RTL layout
updateRTL();
}
// Update RTL layout
function updateRTL() {
const html = document.documentElement;
const body = document.body;
if (isRTL()) {
html.setAttribute('dir', 'rtl');
html.setAttribute('lang', currentLang);
body.classList.add('rtl');
} else {
html.removeAttribute('dir');
html.setAttribute('lang', currentLang);
body.classList.remove('rtl');
}
} }
// Initialize - load saved language only // Initialize - load saved language only
@@ -88,7 +115,8 @@ const i18n = (() => {
t, t,
setLanguage, setLanguage,
getAvailableLanguages: () => availableLanguages, getAvailableLanguages: () => availableLanguages,
getCurrentLanguage: () => currentLang getCurrentLanguage: () => currentLang,
isRTL
}; };
})(); })();

View File

@@ -66,6 +66,113 @@ async function openLogsFolder() {
await window.electronAPI.openLogsFolder(); await window.electronAPI.openLogsFolder();
} }
async function sendLogs() {
const btn = document.getElementById('sendLogsBtn');
if (!btn || btn.disabled) return;
// Get i18n strings with fallbacks
const i18n = window.i18n || {};
const sendingText = (i18n.settings && i18n.settings.logsSending) || 'Sending...';
const sentText = (i18n.settings && i18n.settings.logsSent) || 'Sent!';
const failedText = (i18n.settings && i18n.settings.logsSendFailed) || 'Failed';
const sendText = (i18n.settings && i18n.settings.logsSend) || 'Send Logs';
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${sendingText}`;
try {
const result = await window.electronAPI.sendLogs();
if (result.success) {
btn.innerHTML = `<i class="fas fa-check"></i> ${sentText}`;
showLogSubmissionResult(result.id);
} else {
btn.innerHTML = `<i class="fas fa-times"></i> ${failedText}`;
console.error('Send logs failed:', result.error);
// Show error notification if available
if (window.LauncherUI && window.LauncherUI.showNotification) {
window.LauncherUI.showNotification(result.error || 'Failed to send logs', 'error');
}
}
} catch (err) {
console.error('Send logs error:', err);
btn.innerHTML = `<i class="fas fa-times"></i> ${failedText}`;
}
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = originalHTML;
}, 3000);
}
function showLogSubmissionResult(id) {
// Remove existing popup if any
const existing = document.getElementById('logSubmissionPopup');
if (existing) existing.remove();
const i18n = window.i18n || {};
const idLabel = (i18n.settings && i18n.settings.logsSubmissionId) || 'Submission ID';
const copyText = (i18n.common && i18n.common.copy) || 'Copy';
const closeText = (i18n.common && i18n.common.close) || 'Close';
const shareText = (i18n.settings && i18n.settings.logsShareId) || 'Share this ID with support when reporting issues';
const popup = document.createElement('div');
popup.id = 'logSubmissionPopup';
popup.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: rgba(20, 20, 35, 0.98); border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 12px; padding: 24px 32px; z-index: 10000;
box-shadow: 0 20px 60px rgba(0,0,0,0.5); text-align: center;
min-width: 320px; backdrop-filter: blur(10px);
`;
popup.innerHTML = `
<div style="margin-bottom: 16px;">
<i class="fas fa-check-circle" style="font-size: 2em; color: #00d4ff;"></i>
</div>
<div style="color: #888; font-size: 0.85em; margin-bottom: 8px;">${idLabel}</div>
<div id="logSubId" style="font-family: monospace; font-size: 1.5em; color: #00d4ff; letter-spacing: 2px; margin-bottom: 12px; user-select: all;">${id}</div>
<div style="color: #666; font-size: 0.8em; margin-bottom: 20px;">${shareText}</div>
<div style="display: flex; gap: 10px; justify-content: center;">
<button onclick="copyLogSubmissionId('${id}')" style="
background: rgba(0,212,255,0.2); border: 1px solid rgba(0,212,255,0.3);
color: #00d4ff; padding: 8px 20px; border-radius: 6px; cursor: pointer;
font-size: 0.9em;
"><i class="fas fa-copy"></i> ${copyText}</button>
<button onclick="document.getElementById('logSubmissionPopup').remove()" style="
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2);
color: #ccc; padding: 8px 20px; border-radius: 6px; cursor: pointer;
font-size: 0.9em;
">${closeText}</button>
</div>
`;
document.body.appendChild(popup);
// Auto-close after 30s
setTimeout(() => {
if (document.getElementById('logSubmissionPopup')) {
popup.remove();
}
}, 30000);
}
async function copyLogSubmissionId(id) {
try {
await navigator.clipboard.writeText(id);
const btn = event.target.closest('button');
if (btn) {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
setTimeout(() => { btn.innerHTML = orig; }, 1500);
}
} catch (err) {
console.error('Failed to copy submission ID:', err);
}
}
function openLogs() { function openLogs() {
// Navigation is handled by sidebar logic, but we can trigger a refresh // Navigation is handled by sidebar logic, but we can trigger a refresh
window.LauncherUI.showPage('logs-page'); window.LauncherUI.showPage('logs-page');
@@ -77,6 +184,8 @@ function openLogs() {
window.refreshLogs = refreshLogs; window.refreshLogs = refreshLogs;
window.copyLogs = copyLogs; window.copyLogs = copyLogs;
window.openLogsFolder = openLogsFolder; window.openLogsFolder = openLogsFolder;
window.sendLogs = sendLogs;
window.copyLogSubmissionId = copyLogSubmissionId;
window.openLogs = openLogs; window.openLogs = openLogs;
// Auto-load logs when the page becomes active // Auto-load logs when the page becomes active

View File

@@ -569,6 +569,7 @@ async function loadAllSettings() {
await loadLauncherHwAccel(); await loadLauncherHwAccel();
await loadGpuPreference(); await loadGpuPreference();
await loadVersionBranch(); await loadVersionBranch();
await loadWrapperConfigUI();
} }
@@ -1254,3 +1255,235 @@ async function loadVersionBranch() {
return 'release'; return 'release';
} }
} }
// === Java Wrapper Configuration UI ===
let _wrapperConfig = null;
let _wrapperPreviewOpen = false;
async function loadWrapperConfigUI() {
try {
if (!window.electronAPI || !window.electronAPI.loadWrapperConfig) return;
_wrapperConfig = await window.electronAPI.loadWrapperConfig();
renderStripFlagsList();
renderInjectArgsList();
setupWrapperEventListeners();
} catch (error) {
console.error('Error loading wrapper config UI:', error);
}
}
function renderStripFlagsList() {
const container = document.getElementById('wrapperStripFlagsList');
if (!container || !_wrapperConfig) return;
if (_wrapperConfig.stripFlags.length === 0) {
container.innerHTML = '<div class="wrapper-items-empty">No flags configured</div>';
return;
}
container.innerHTML = '';
_wrapperConfig.stripFlags.forEach((flag, index) => {
const item = document.createElement('div');
item.className = 'wrapper-item';
item.innerHTML = `
<span class="wrapper-item-text">${escapeHtml(flag)}</span>
<button class="wrapper-item-delete" data-index="${index}" title="Remove">
<i class="fas fa-trash-alt"></i>
</button>
`;
item.querySelector('.wrapper-item-delete').addEventListener('click', () => removeStripFlag(index));
container.appendChild(item);
});
}
function renderInjectArgsList() {
const container = document.getElementById('wrapperInjectArgsList');
if (!container || !_wrapperConfig) return;
if (_wrapperConfig.injectArgs.length === 0) {
container.innerHTML = '<div class="wrapper-items-empty">No arguments configured</div>';
return;
}
container.innerHTML = '';
_wrapperConfig.injectArgs.forEach((entry, index) => {
const item = document.createElement('div');
item.className = 'wrapper-item';
const serverLabel = window.i18n ? window.i18n.t('settings.wrapperConditionServer') : 'Server Only';
const alwaysLabel = window.i18n ? window.i18n.t('settings.wrapperConditionAlways') : 'Always';
item.innerHTML = `
<span class="wrapper-item-text">${escapeHtml(entry.arg)}</span>
<div class="wrapper-item-condition">
<select data-index="${index}">
<option value="server"${entry.condition === 'server' ? ' selected' : ''}>${serverLabel}</option>
<option value="always"${entry.condition === 'always' ? ' selected' : ''}>${alwaysLabel}</option>
</select>
</div>
<button class="wrapper-item-delete" data-index="${index}" title="Remove">
<i class="fas fa-trash-alt"></i>
</button>
`;
item.querySelector('select').addEventListener('change', (e) => updateArgCondition(index, e.target.value));
item.querySelector('.wrapper-item-delete').addEventListener('click', () => removeInjectArg(index));
container.appendChild(item);
});
}
async function addStripFlag() {
const input = document.getElementById('wrapperAddFlagInput');
if (!input || !_wrapperConfig) return;
const flag = input.value.trim();
if (!flag) return;
if (_wrapperConfig.stripFlags.includes(flag)) {
const msg = window.i18n ? window.i18n.t('notifications.wrapperFlagExists') : 'This flag is already in the list';
showNotification(msg, 'error');
return;
}
_wrapperConfig.stripFlags.push(flag);
input.value = '';
renderStripFlagsList();
await saveWrapperConfigToBackend();
await updateWrapperPreview();
}
async function removeStripFlag(index) {
if (!_wrapperConfig) return;
_wrapperConfig.stripFlags.splice(index, 1);
renderStripFlagsList();
await saveWrapperConfigToBackend();
await updateWrapperPreview();
}
async function addInjectArg() {
const input = document.getElementById('wrapperAddArgInput');
const condSelect = document.getElementById('wrapperAddArgCondition');
if (!input || !condSelect || !_wrapperConfig) return;
const arg = input.value.trim();
if (!arg) return;
const exists = _wrapperConfig.injectArgs.some(e => e.arg === arg);
if (exists) {
const msg = window.i18n ? window.i18n.t('notifications.wrapperArgExists') : 'This argument is already in the list';
showNotification(msg, 'error');
return;
}
_wrapperConfig.injectArgs.push({ arg, condition: condSelect.value });
input.value = '';
renderInjectArgsList();
await saveWrapperConfigToBackend();
await updateWrapperPreview();
}
async function removeInjectArg(index) {
if (!_wrapperConfig) return;
_wrapperConfig.injectArgs.splice(index, 1);
renderInjectArgsList();
await saveWrapperConfigToBackend();
await updateWrapperPreview();
}
async function updateArgCondition(index, condition) {
if (!_wrapperConfig || !_wrapperConfig.injectArgs[index]) return;
_wrapperConfig.injectArgs[index].condition = condition;
await saveWrapperConfigToBackend();
await updateWrapperPreview();
}
async function saveWrapperConfigToBackend() {
try {
const result = await window.electronAPI.saveWrapperConfig(_wrapperConfig);
if (!result || !result.success) {
throw new Error(result?.error || 'Save failed');
}
} catch (error) {
console.error('Error saving wrapper config:', error);
const msg = window.i18n ? window.i18n.t('notifications.wrapperConfigSaveFailed') : 'Failed to save wrapper configuration';
showNotification(msg, 'error');
}
}
function setupWrapperEventListeners() {
const addFlagBtn = document.getElementById('wrapperAddFlagBtn');
const addFlagInput = document.getElementById('wrapperAddFlagInput');
const addArgBtn = document.getElementById('wrapperAddArgBtn');
const addArgInput = document.getElementById('wrapperAddArgInput');
const restoreBtn = document.getElementById('wrapperRestoreDefaultsBtn');
const previewToggle = document.getElementById('wrapperPreviewToggle');
if (addFlagBtn) addFlagBtn.addEventListener('click', addStripFlag);
if (addFlagInput) addFlagInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addStripFlag(); });
if (addArgBtn) addArgBtn.addEventListener('click', addInjectArg);
if (addArgInput) addArgInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addInjectArg(); });
if (restoreBtn) {
restoreBtn.addEventListener('click', () => {
const message = window.i18n ? window.i18n.t('confirm.resetWrapperMessage') : 'Are you sure you want to restore defaults? Your custom changes will be lost.';
const title = window.i18n ? window.i18n.t('confirm.resetWrapperTitle') : 'Restore Defaults';
showCustomConfirm(message, title, async () => {
try {
const result = await window.electronAPI.resetWrapperConfig();
if (result && result.success) {
_wrapperConfig = result.config;
renderStripFlagsList();
renderInjectArgsList();
await updateWrapperPreview();
const msg = window.i18n ? window.i18n.t('notifications.wrapperConfigReset') : 'Wrapper configuration restored to defaults';
showNotification(msg, 'success');
} else {
throw new Error(result?.error || 'Reset failed');
}
} catch (error) {
console.error('Error resetting wrapper config:', error);
const msg = window.i18n ? window.i18n.t('notifications.wrapperConfigResetFailed') : 'Failed to restore wrapper configuration';
showNotification(msg, 'error');
}
});
});
}
if (previewToggle) {
previewToggle.addEventListener('click', toggleWrapperPreview);
}
}
async function toggleWrapperPreview() {
const container = document.getElementById('wrapperPreviewContainer');
const chevron = document.getElementById('wrapperPreviewChevron');
if (!container) return;
_wrapperPreviewOpen = !_wrapperPreviewOpen;
if (_wrapperPreviewOpen) {
container.style.display = 'block';
if (chevron) chevron.classList.add('expanded');
await updateWrapperPreview();
} else {
container.style.display = 'none';
if (chevron) chevron.classList.remove('expanded');
}
}
async function updateWrapperPreview() {
if (!_wrapperPreviewOpen || !_wrapperConfig) return;
const previewEl = document.getElementById('wrapperPreviewContent');
if (!previewEl) return;
try {
const platform = await window.electronAPI.getCurrentPlatform();
const script = await window.electronAPI.previewWrapperScript(_wrapperConfig, platform);
previewEl.textContent = script;
} catch (error) {
previewEl.textContent = 'Error generating preview: ' + error.message;
}
}

263
GUI/locales/ar-SA.json Normal file
View File

@@ -0,0 +1,263 @@
{
"nav": {
"play": "لعب",
"mods": "المودات",
"news": "الأخبار",
"chat": "دردشة اللاعبين",
"settings": "الإعدادات"
},
"header": {
"playersLabel": "اللاعبون:",
"manageProfiles": "إدارة الملفات الشخصية",
"defaultProfile": "الافتراضي"
},
"install": {
"title": "مشغل اللعب المجاني",
"playerName": "اسم اللاعب",
"playerNamePlaceholder": "أدخل اسمك",
"gameBranch": "إصدار اللعبة",
"releaseVersion": "إصدار نهائي (مستقر)",
"preReleaseVersion": "إصدار تجريبي (تجريبي)",
"customInstallation": "تثبيت مخصص",
"installationFolder": "مجلد التثبيت",
"pathPlaceholder": "الموقع الافتراضي",
"browse": "تصفح",
"installButton": "تثبيت HYTALE",
"installing": "جاري التثبيت..."
},
"play": {
"ready": "جاهز للعب",
"subtitle": "شغل Hytale وابدأ المغامرة",
"playButton": "لعب HYTALE",
"latestNews": "آخر الأخبار",
"viewAll": "عرض الكل",
"checking": "جاري التحقق...",
"play": "بدء"
},
"mods": {
"searchPlaceholder": "البحث عن مودات...",
"myMods": "موداتي",
"previous": "السابق",
"next": "التالي",
"page": "صفحة",
"of": "من",
"modalTitle": "موداتي",
"noModsFound": "لم يتم العثور على مودات",
"noModsFoundDesc": "حاول تعديل معايير البحث",
"noModsInstalled": "لا توجد مودات مثبتة",
"noModsInstalledDesc": "أضف مودات من CurseForge أو استورد ملفات محلية",
"view": "عرض",
"install": "تثبيت",
"installed": "مثبت",
"enable": "تفعيل",
"disable": "تعطيل",
"active": "نشط",
"disabled": "معطل",
"delete": "حذف المود",
"noDescription": "لا يوجد وصف متاح",
"confirmDelete": "هل أنت متأكد أنك تريد حذف \"{name}\"؟",
"confirmDeleteDesc": "لا يمكن التراجع عن هذا الإجراء.",
"confirmDeletion": "تأكيد الحذف",
"apiKeyRequired": "مطلوب مفتاح API",
"apiKeyRequiredDesc": "مطلوب مفتاح CurseForge API لتصفح المودات"
},
"news": {
"title": "كل الأخبار",
"readMore": "اقرأ المزيد"
},
"chat": {
"title": "دردشة اللاعبين",
"pickColor": "اللون",
"inputPlaceholder": "اكتب رسالتك...",
"send": "إرسال",
"online": "متصل",
"charCounter": "{current}/{max}",
"secureChat": "دردشة آمنة - الروابط محجوبة",
"joinChat": "انضمام للدردشة",
"chooseUsername": "اختر اسم مستخدم للانضمام إلى دردشة اللاعبين",
"username": "اسم المستخدم",
"usernamePlaceholder": "أدخل اسم المستخدم...",
"usernameHint": "3-20 حرفاً، حروف، أرقام، و - و _ فقط",
"joinButton": "انضمام",
"colorModal": {
"title": "تخصيص لون اسم المستخدم",
"chooseSolid": "اختر لوناً ثابتاً:",
"customColor": "لون مخصص:",
"preview": "معاينة:",
"previewUsername": "اسم المستخدم",
"apply": "تطبيق اللون"
}
},
"settings": {
"title": "الإعدادات",
"java": "بيئة تشغيل جافا",
"useCustomJava": "استخدام مسار جافا مخصص",
"javaDescription": "تجاوز بيئة جافا المرفقة واستخدام تثبيت خاص بك",
"javaPath": "مسار ملف جافا التنفيذي",
"javaPathPlaceholder": "اختر مسار جافا...",
"javaBrowse": "تصفح",
"javaHint": "اختر مجلد تثبيت جافا (يدعم ويندوز، ماك، ولينكس)",
"discord": "تكامل ديسكورد",
"enableRPC": "تفعيل نشاط ديسكورد (Rich Presence)",
"discordDescription": "إظهار نشاط المشغل الخاص بك على ديسكورد",
"game": "خيارات اللعبة",
"playerName": "اسم اللاعب",
"playerNamePlaceholder": "أدخل اسم اللاعب",
"playerNameHint": "سيتم استخدام هذا الاسم داخل اللعبة (1-16 حرفاً)",
"openGameLocation": "فتح موقع اللعبة",
"openGameLocationDesc": "فتح مجلد تثبيت اللعبة",
"account": "إدارة UUID اللاعب",
"currentUUID": "الـ UUID الحالي",
"uuidPlaceholder": "جاري تحميل UUID...",
"copyUUID": "نسخ UUID",
"regenerateUUID": "إعادة إنشاء UUID",
"uuidHint": "معرف اللاعب الفريد الخاص بك لهذا الاسم",
"manageUUIDs": "إدارة جميع الـ UUIDs",
"manageUUIDsDesc": "عرض وإدارة جميع معرفات اللاعبين",
"language": "اللغة",
"selectLanguage": "اختر اللغة",
"repairGame": "إصلاح اللعبة",
"reinstallGame": "إعادة تثبيت ملفات اللعبة (يحفظ البيانات)",
"gpuPreference": "تفضيل معالج الرسوميات (GPU)",
"gpuHint": "ميزة للمحمول فقط؛ اضبطها على Integrated إذا كنت تستخدم كمبيوتر مكتبي",
"gpuAuto": "تلقائي",
"gpuIntegrated": "مدمج",
"gpuDedicated": "منفصل",
"logs": "سجلات النظام",
"logsCopy": "نسخ",
"logsRefresh": "تحديث",
"logsFolder": "فتح المجلد",
"logsSend": "إرسال السجلات",
"logsSending": "جارٍ الإرسال...",
"logsSent": "تم الإرسال!",
"logsSendFailed": "فشل",
"logsSubmissionId": "معرف الإرسال",
"logsShareId": "شارك هذا المعرف مع الدعم عند الإبلاغ عن المشاكل",
"logsLoading": "جاري تحميل السجلات...",
"closeLauncher": "سلوك المشغل",
"closeOnStart": "إغلاق المشغل عند بدء اللعبة",
"closeOnStartDescription": "إغلاق المشغل تلقائياً بعد تشغيل Hytale",
"hwAccel": "تسريع الأجهزة (Hardware Acceleration)",
"hwAccelDescription": "تفعيل تسريع الأجهزة للمشغل",
"gameBranch": "فرع اللعبة",
"branchRelease": "إصدار نهائي",
"branchPreRelease": "إصدار تجريبي",
"branchHint": "التبديل بين الإصدار المستقر والإصدار التجريبي",
"branchWarning": "تغيير الفرع سيؤدي إلى تحميل وتثبيت نسخة مختلفة من اللعبة",
"branchSwitching": "جاري التبديل إلى {branch}...",
"branchSwitched": "تم التبديل إلى {branch} بنجاح!",
"installRequired": "التثبيت مطلوب",
"branchInstallConfirm": "سيتم تثبيت اللعبة لفرع {branch}. هل تريد الاستمرار؟"
},
"uuid": {
"modalTitle": "إدارة UUID",
"currentUserUUID": "UUID المستخدم الحالي",
"allPlayerUUIDs": "جميع معرفات UUID للاعبين",
"generateNew": "إنشاء UUID جديد",
"loadingUUIDs": "جاري تحميل الـ UUIDs...",
"setCustomUUID": "تعيين UUID مخصص",
"customPlaceholder": "أدخل UUID مخصص (الصيغة: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
"setUUID": "تعيين UUID",
"warning": "تحذير: تعيين UUID مخصص سيغير هوية اللاعب الحالية",
"copyTooltip": "نسخ UUID",
"regenerateTooltip": "إنشاء UUID جديد"
},
"profiles": {
"modalTitle": "إدارة الملفات الشخصية",
"newProfilePlaceholder": "اسم الملف الشخصي الجديد",
"createProfile": "إنشاء ملف شخصي"
},
"discord": {
"notificationText": "انضم إلى مجتمعنا على ديسكورد!",
"joinButton": "انضم إلى ديسكورد"
},
"common": {
"confirm": "تأكيد",
"cancel": "إلغاء",
"save": "حفظ",
"close": "إغلاق",
"delete": "حذف",
"edit": "تعديل",
"loading": "جاري التحميل...",
"apply": "تطبيق",
"install": "تثبيت"
},
"notifications": {
"gameDataNotFound": "خطأ: لم يتم العثور على بيانات اللعبة",
"gameUpdatedSuccess": "تم تحديث اللعبة بنجاح! 🎉",
"updateFailed": "فشل التحديث: {error}",
"updateError": "خطأ في التحديث: {error}",
"discordEnabled": "تم تفعيل نشاط ديسكورد",
"discordDisabled": "تم تعطيل نشاط ديسكورد",
"discordSaveFailed": "فشل حفظ إعدادات ديسكورد",
"playerNameRequired": "يرجى إدخال اسم لاعب صالح",
"playerNameSaved": "تم حفظ اسم اللاعب بنجاح",
"playerNameSaveFailed": "فشل حفظ اسم اللاعب",
"uuidCopied": "تم نسخ الـ UUID إلى الحافظة!",
"uuidCopyFailed": "فشل نسخ الـ UUID",
"uuidRegenNotAvailable": "إعادة إنشاء UUID غير متاحة",
"uuidRegenFailed": "فشل إعادة إنشاء الـ UUID",
"uuidGenerated": "تم إنشاء UUID جديد بنجاح!",
"uuidGeneratedShort": "تم إنشاء UUID جديد!",
"uuidGenerateFailed": "فشل إنشاء UUID جديد",
"uuidRequired": "يرجى إدخال UUID",
"uuidInvalidFormat": "صيغة UUID غير صالحة",
"uuidSetFailed": "فشل تعيين الـ UUID المخصص",
"uuidSetSuccess": "تم تعيين الـ UUID المخصص بنجاح!",
"uuidDeleteFailed": "فشل حذف الـ UUID",
"uuidDeleteSuccess": "تم حذف الـ UUID بنجاح!",
"modsDownloading": "جاري تحميل {name}...",
"modsTogglingMod": "جاري تبديل حالة المود...",
"modsDeletingMod": "جاري حذف المود...",
"modsLoadingMods": "جاري تحميل المودات من CurseForge...",
"modsInstalledSuccess": "تم تثبيت {name} بنجاح! 🎉",
"modsDeletedSuccess": "تم حذف {name} بنجاح",
"modsDownloadFailed": "فشل تحميل المود: {error}",
"modsToggleFailed": "فشل تبديل المود: {error}",
"modsDeleteFailed": "فشل حذف المود: {error}",
"modsModNotFound": "لم يتم العثور على معلومات المود",
"hwAccelSaved": "تم حفظ إعداد تسريع الأجهزة",
"hwAccelSaveFailed": "فشل حفظ إعداد تسريع الأجهزة",
"noUsername": "لم يتم تهيئة اسم مستخدم. يرجى حفظ اسم المستخدم أولاً.",
"switchUsernameSuccess": "تم التبديل إلى المستخدم \"{username}\" بنجاح!",
"switchUsernameFailed": "فشل تبديل اسم المستخدم",
"playerNameTooLong": "يجب أن يكون اسم اللاعب 16 حرفاً أو أقل"
},
"confirm": {
"defaultTitle": "تأكيد الإجراء",
"regenerateUuidTitle": "إنشاء UUID جديد",
"regenerateUuidMessage": "هل أنت متأكد أنك تريد إنشاء UUID جديد؟ سيؤدي ذلك إلى تغيير هوية اللاعب الخاصة بك.",
"regenerateUuidButton": "إنشاء",
"setCustomUuidTitle": "تعيين UUID مخصص",
"setCustomUuidMessage": "هل أنت متأكد أنك تريد تعيين هذا الـ UUID المخصص؟ سيؤدي ذلك إلى تغيير هوية اللاعب الخاصة بك.",
"setCustomUuidButton": "تعيين UUID",
"deleteUuidTitle": "حذف UUID",
"deleteUuidMessage": "هل أنت متأكد أنك تريد حذف الـ UUID الخاص بـ \"{username}\"؟ لا يمكن التراجع عن هذا الإجراء.",
"deleteUuidButton": "حذف",
"uninstallGameTitle": "إلغاء تثبيت اللعبة",
"uninstallGameMessage": "هل أنت متأكد أنك تريد إلغاء تثبيت Hytale؟ سيتم حذف جميع ملفات اللعبة.",
"uninstallGameButton": "إلغاء التثبيت",
"switchUsernameTitle": "تبديل الهوية",
"switchUsernameMessage": "التبديل إلى اسم المستخدم \"{username}\"؟ سيؤدي هذا إلى تغيير هوية اللاعب الحالية.",
"switchUsernameButton": "تبديل"
},
"progress": {
"initializing": "جاري التهيئة...",
"downloading": "جاري التحميل...",
"installing": "جاري التثبيت...",
"extracting": "جاري الاستخراج...",
"verifying": "جاري التحقق...",
"switchingProfile": "جاري تبديل الملف الشخصي...",
"profileSwitched": "تم تبديل الملف الشخصي!",
"startingGame": "جاري بدء اللعبة...",
"launching": "جاري التشغيل...",
"uninstallingGame": "جاري إلغاء تثبيت اللعبة...",
"gameUninstalled": "تم إلغاء تثبيت اللعبة بنجاح!",
"uninstallFailed": "فشل إلغاء التثبيت: {error}",
"startingUpdate": "جاري بدء تحديث اللعبة الإجباري...",
"installationComplete": "تم اكتمال التثبيت بنجاح!",
"installationFailed": "فشل التثبيت: {error}",
"installingGameFiles": "جاري تثبيت ملفات اللعبة...",
"installComplete": "اكتمل التثبيت!"
}
}

View File

@@ -127,6 +127,12 @@
"logsCopy": "Kopieren", "logsCopy": "Kopieren",
"logsRefresh": "Aktualisieren", "logsRefresh": "Aktualisieren",
"logsFolder": "Ordner öffnen", "logsFolder": "Ordner öffnen",
"logsSend": "Logs senden",
"logsSending": "Senden...",
"logsSent": "Gesendet!",
"logsSendFailed": "Fehlgeschlagen",
"logsSubmissionId": "Einreichungs-ID",
"logsShareId": "Teilen Sie diese ID dem Support mit, wenn Sie Probleme melden",
"logsLoading": "Protokolle werden geladen...", "logsLoading": "Protokolle werden geladen...",
"closeLauncher": "Launcher-Verhalten", "closeLauncher": "Launcher-Verhalten",
"closeOnStart": "Launcher beim Spielstart schließen", "closeOnStart": "Launcher beim Spielstart schließen",

View File

@@ -127,6 +127,12 @@
"logsCopy": "Copy", "logsCopy": "Copy",
"logsRefresh": "Refresh", "logsRefresh": "Refresh",
"logsFolder": "Open Folder", "logsFolder": "Open Folder",
"logsSend": "Send Logs",
"logsSending": "Sending...",
"logsSent": "Sent!",
"logsSendFailed": "Failed",
"logsSubmissionId": "Submission ID",
"logsShareId": "Share this ID with support when reporting issues",
"logsLoading": "Loading logs...", "logsLoading": "Loading logs...",
"closeLauncher": "Launcher Behavior", "closeLauncher": "Launcher Behavior",
"closeOnStart": "Close Launcher on game start", "closeOnStart": "Close Launcher on game start",
@@ -141,7 +147,18 @@
"branchSwitching": "Switching to {branch}...", "branchSwitching": "Switching to {branch}...",
"branchSwitched": "Switched to {branch} successfully!", "branchSwitched": "Switched to {branch} successfully!",
"installRequired": "Installation Required", "installRequired": "Installation Required",
"branchInstallConfirm": "The game will be installed for the {branch} branch. Continue?" "branchInstallConfirm": "The game will be installed for the {branch} branch. Continue?",
"wrapperConfig": "Java Wrapper Configuration",
"wrapperConfigHint": "Configure how the Java wrapper handles JVM flags and arguments at launch time.",
"wrapperStripFlags": "JVM Flags to Remove",
"wrapperInjectArgs": "Arguments to Inject",
"wrapperAddFlagPlaceholder": "e.g. -XX:+SomeFlag",
"wrapperAddArgPlaceholder": "e.g. --some-flag",
"wrapperAdd": "Add",
"wrapperConditionServer": "Server Only",
"wrapperConditionAlways": "Always",
"wrapperRestoreDefaults": "Restore Defaults",
"wrapperAdvancedPreview": "Advanced: Script Preview"
}, },
"uuid": { "uuid": {
"modalTitle": "UUID Management", "modalTitle": "UUID Management",
@@ -215,7 +232,13 @@
"noUsername": "No username configured. Please save your username first.", "noUsername": "No username configured. Please save your username first.",
"switchUsernameSuccess": "Switched to \"{username}\" successfully!", "switchUsernameSuccess": "Switched to \"{username}\" successfully!",
"switchUsernameFailed": "Failed to switch username", "switchUsernameFailed": "Failed to switch username",
"playerNameTooLong": "Player name must be 16 characters or less" "playerNameTooLong": "Player name must be 16 characters or less",
"wrapperConfigSaved": "Wrapper configuration saved",
"wrapperConfigSaveFailed": "Failed to save wrapper configuration",
"wrapperConfigReset": "Wrapper configuration restored to defaults",
"wrapperConfigResetFailed": "Failed to restore wrapper configuration",
"wrapperFlagExists": "This flag is already in the list",
"wrapperArgExists": "This argument is already in the list"
}, },
"confirm": { "confirm": {
"defaultTitle": "Confirm action", "defaultTitle": "Confirm action",
@@ -233,7 +256,9 @@
"uninstallGameButton": "Uninstall", "uninstallGameButton": "Uninstall",
"switchUsernameTitle": "Switch Identity", "switchUsernameTitle": "Switch Identity",
"switchUsernameMessage": "Switch to username \"{username}\"? This will change your current player identity.", "switchUsernameMessage": "Switch to username \"{username}\"? This will change your current player identity.",
"switchUsernameButton": "Switch" "switchUsernameButton": "Switch",
"resetWrapperTitle": "Restore Defaults",
"resetWrapperMessage": "Are you sure you want to restore the default wrapper configuration? Your custom changes will be lost."
}, },
"progress": { "progress": {
"initializing": "Initializing...", "initializing": "Initializing...",

View File

@@ -127,6 +127,12 @@
"logsCopy": "Copiar", "logsCopy": "Copiar",
"logsRefresh": "Actualizar", "logsRefresh": "Actualizar",
"logsFolder": "Abrir Carpeta", "logsFolder": "Abrir Carpeta",
"logsSend": "Enviar logs",
"logsSending": "Enviando...",
"logsSent": "Enviado!",
"logsSendFailed": "Fallido",
"logsSubmissionId": "ID de envío",
"logsShareId": "Comparte este ID con soporte al reportar problemas",
"logsLoading": "Cargando registros...", "logsLoading": "Cargando registros...",
"closeLauncher": "Comportamiento del Launcher", "closeLauncher": "Comportamiento del Launcher",
"closeOnStart": "Cerrar Launcher al iniciar el juego", "closeOnStart": "Cerrar Launcher al iniciar el juego",

View File

@@ -127,6 +127,12 @@
"logsCopy": "Copier", "logsCopy": "Copier",
"logsRefresh": "Actualiser", "logsRefresh": "Actualiser",
"logsFolder": "Ouvrir le Dossier", "logsFolder": "Ouvrir le Dossier",
"logsSend": "Envoyer les logs",
"logsSending": "Envoi...",
"logsSent": "Envoyé !",
"logsSendFailed": "Échoué",
"logsSubmissionId": "ID de soumission",
"logsShareId": "Partagez cet ID avec le support pour signaler des problèmes",
"logsLoading": "Chargement des journaux...", "logsLoading": "Chargement des journaux...",
"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",

View File

@@ -127,6 +127,12 @@
"logsCopy": "Salin", "logsCopy": "Salin",
"logsRefresh": "Segarkan", "logsRefresh": "Segarkan",
"logsFolder": "Buka Folder", "logsFolder": "Buka Folder",
"logsSend": "Kirim Log",
"logsSending": "Mengirim...",
"logsSent": "Terkirim!",
"logsSendFailed": "Gagal",
"logsSubmissionId": "ID Pengiriman",
"logsShareId": "Bagikan ID ini ke dukungan saat melaporkan masalah",
"logsLoading": "Memuat log...", "logsLoading": "Memuat log...",
"closeLauncher": "Perilaku Launcher", "closeLauncher": "Perilaku Launcher",
"closeOnStart": "Tutup launcher saat game dimulai", "closeOnStart": "Tutup launcher saat game dimulai",

View File

@@ -127,6 +127,12 @@
"logsCopy": "Kopiuj", "logsCopy": "Kopiuj",
"logsRefresh": "Odśwież", "logsRefresh": "Odśwież",
"logsFolder": "Otwórz Folder", "logsFolder": "Otwórz Folder",
"logsSend": "Wyślij logi",
"logsSending": "Wysyłanie...",
"logsSent": "Wysłano!",
"logsSendFailed": "Błąd",
"logsSubmissionId": "ID zgłoszenia",
"logsShareId": "Udostępnij ten ID wsparciu technicznemu przy zgłaszaniu problemów",
"logsLoading": "Ładowanie logów...", "logsLoading": "Ładowanie logów...",
"closeLauncher": "Zachowanie Launchera", "closeLauncher": "Zachowanie Launchera",
"closeOnStart": "Zamknij Launcher przy starcie gry", "closeOnStart": "Zamknij Launcher przy starcie gry",

View File

@@ -127,6 +127,12 @@
"logsCopy": "Copiar", "logsCopy": "Copiar",
"logsRefresh": "Atualizar", "logsRefresh": "Atualizar",
"logsFolder": "Abrir Pasta", "logsFolder": "Abrir Pasta",
"logsSend": "Enviar logs",
"logsSending": "Enviando...",
"logsSent": "Enviado!",
"logsSendFailed": "Falhou",
"logsSubmissionId": "ID de envio",
"logsShareId": "Compartilhe este ID com o suporte ao relatar problemas",
"logsLoading": "Carregando registros...", "logsLoading": "Carregando registros...",
"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",

View File

@@ -127,6 +127,12 @@
"logsCopy": "Копировать", "logsCopy": "Копировать",
"logsRefresh": "Обновить", "logsRefresh": "Обновить",
"logsFolder": "Открыть папку", "logsFolder": "Открыть папку",
"logsSend": "Отправить логи",
"logsSending": "Отправка...",
"logsSent": "Отправлено!",
"logsSendFailed": "Ошибка",
"logsSubmissionId": "ID отправки",
"logsShareId": "Поделитесь этим ID с поддержкой при обращении",
"logsLoading": "Загрузка логов...", "logsLoading": "Загрузка логов...",
"closeLauncher": "Поведение лаунчера", "closeLauncher": "Поведение лаунчера",
"closeOnStart": "Закрыть лаунчер при старте игры", "closeOnStart": "Закрыть лаунчер при старте игры",

View File

@@ -127,6 +127,12 @@
"logsCopy": "Kopiera", "logsCopy": "Kopiera",
"logsRefresh": "Uppdatera", "logsRefresh": "Uppdatera",
"logsFolder": "Öppna mapp", "logsFolder": "Öppna mapp",
"logsSend": "Skicka loggar",
"logsSending": "Skickar...",
"logsSent": "Skickat!",
"logsSendFailed": "Misslyckades",
"logsSubmissionId": "Inlämnings-ID",
"logsShareId": "Dela detta ID med support vid felanmälan",
"logsLoading": "Laddar loggar...", "logsLoading": "Laddar loggar...",
"closeLauncher": "Launcher-beteende", "closeLauncher": "Launcher-beteende",
"closeOnStart": "Stäng launcher vid spelstart", "closeOnStart": "Stäng launcher vid spelstart",

View File

@@ -127,6 +127,12 @@
"logsCopy": "Kopyala", "logsCopy": "Kopyala",
"logsRefresh": "Yenile", "logsRefresh": "Yenile",
"logsFolder": "Klasörü Aç", "logsFolder": "Klasörü Aç",
"logsSend": "Logları Gönder",
"logsSending": "Gönderiliyor...",
"logsSent": "Gönderildi!",
"logsSendFailed": "Başarısız",
"logsSubmissionId": "Gönderim ID",
"logsShareId": "Sorun bildirirken bu ID'yi destekle paylaşın",
"logsLoading": "Loglar yükleniyor...", "logsLoading": "Loglar yükleniyor...",
"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",

209
GUI/style-RTL.css Normal file
View File

@@ -0,0 +1,209 @@
body.rtl {
direction: rtl;
font-family: 'Noto Sans Arabic', 'Space Grotesk', sans-serif;
}
body.rtl .sidebar {
right: 0;
left: auto;
border-right: none;
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
body.rtl .nav-item.active::before {
right: -8px;
left: auto;
border-radius: 4px 0 0 4px;
}
body.rtl .nav-tooltip {
right: 100%;
left: auto;
margin-right: 0.5rem;
margin-left: 0;
}
body.rtl .nav-item:hover .nav-tooltip {
transform: translateX(-8px);
}
body.rtl .main-content {
margin-right: 80px;
margin-left: 0;
}
/* Header Layout*/
body.rtl .players-counter {
order: 2;
margin-left: 1.5rem;
margin-right: 0;
}
body.rtl .profile-selector {
order: -1;
}
body.rtl .window-controls {
order: 3;
flex-direction: row;
}
body.rtl .profile-dropdown {
right: auto;
left: 0;
}
body.rtl .form-group {
text-align: right;
}
body.rtl .radio-label,
body.rtl .checkbox-group {
flex-direction: row-reverse;
}
body.rtl .form-input {
border-radius: 0 8px 8px 0;
}
body.rtl .mods-pagination {
flex-direction: row-reverse;
}
body.rtl .pagination-btn:first-child i {
transform: rotate(180deg);
}
body.rtl .pagination-btn:last-child i {
transform: rotate(180deg);
}
/* UUID Display */
body.rtl .uuid-display-container {
flex-direction: row-reverse;
}
body.rtl .uuid-btn {
border-radius: 0 8px 8px 0;
}
body.rtl .uuid-input {
border-radius: 8px 0 0 8px;
}
body.rtl .segmented-control {
flex-direction: row-reverse;
}
/* Mod Grid Layout */
body.rtl .mods-search {
text-align: right;
}
body.rtl .mods-search-container {
flex-direction: row-reverse;
}
body.rtl .mods-actions {
order: -1;
}
body.rtl .mod-card {
direction: rtl;
}
body.rtl .installed-mod-card {
direction: rtl;
}
body.rtl .installed-mod-card .mod-info {
text-align: right;
}
body.rtl .mods-header {
flex-direction: row-reverse;
}
body.rtl .news-section .news-header {
flex-direction: row-reverse;
}
/* Settings Layout */
body.rtl .settings-option {
text-align: right;
}
body.rtl .settings-input-group {
text-align: right;
}
body.rtl .settings-input {
border-radius: 0 8px 8px 0;
}
body.rtl .settings-section-title i {
margin-right: 0;
margin-left: 0.5rem;
}
body.rtl .settings-hint i {
margin-right: 0;
margin-left: 0.5rem;
}
body.rtl .custom-options,
body.rtl .custom-java-options {
text-align: right;
}
body.rtl .checkbox-content {
margin-right: 2rem;
margin-left: 0;
}
body.rtl .btn-content {
text-align: right;
}
/* Icons & Transformations */
body.rtl .news-title i {
padding-left: 0.5rem;
}
body.rtl .uuid-modal-title i {
padding-left: 0.5rem;
}
body.rtl .mods-modal-title i {
padding-left: 0.5rem;
}
body.rtl .view-all-btn i,
body.rtl .sidebar-nav div i,
body.rtl .logs-header i,
body.rtl .home-play-button i {
transform: scaleX(-1);
}
body.rtl .play-title i {
transform: scaleX(-1);
padding-right: 0.5rem;
}
body.rtl .logs-terminal {
direction: ltr;
text-align: left;
}
body.rtl .version-display-bottom {
right: auto;
left: 1rem;
}

View File

@@ -873,6 +873,22 @@ body {
border-color: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.2);
} }
.logs-action-btn.logs-send-btn {
background: rgba(0, 212, 255, 0.15);
border-color: rgba(0, 212, 255, 0.3);
color: #00d4ff;
}
.logs-action-btn.logs-send-btn:hover {
background: rgba(0, 212, 255, 0.25);
border-color: rgba(0, 212, 255, 0.5);
}
.logs-action-btn.logs-send-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.logs-terminal { .logs-terminal {
flex: 1; flex: 1;
background: #0d1117; background: #0d1117;
@@ -4813,6 +4829,140 @@ select.settings-input option {
transform: translateY(0); transform: translateY(0);
} }
.wrapper-items-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.wrapper-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
transition: all 0.3s ease;
}
.wrapper-item:hover {
border-color: rgba(147, 51, 234, 0.3);
background: rgba(147, 51, 234, 0.05);
}
.wrapper-item-text {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 0.82rem;
color: #e5e7eb;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wrapper-item-condition select {
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #e5e7eb;
font-size: 0.75rem;
padding: 4px 8px;
cursor: pointer;
margin: 0 8px;
}
.wrapper-item-condition select:focus {
outline: none;
border-color: rgba(147, 51, 234, 0.5);
}
.wrapper-item-delete {
padding: 4px 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all 0.3s ease;
font-size: 0.8rem;
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
}
.wrapper-item-delete:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
}
.wrapper-items-empty {
font-style: italic;
text-align: center;
color: rgba(255, 255, 255, 0.4);
padding: 12px;
font-size: 0.82rem;
}
.wrapper-condition-select {
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #e5e7eb;
font-size: 0.82rem;
padding: 6px 10px;
cursor: pointer;
}
.wrapper-condition-select:focus {
outline: none;
border-color: rgba(147, 51, 234, 0.5);
}
.wrapper-preview-toggle {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 0.82rem;
padding: 4px 0;
display: flex;
align-items: center;
gap: 6px;
transition: color 0.2s;
}
.wrapper-preview-toggle:hover {
color: rgba(255, 255, 255, 0.9);
}
.wrapper-preview-toggle i {
transition: transform 0.2s;
font-size: 0.7rem;
}
.wrapper-preview-toggle i.expanded {
transform: rotate(90deg);
}
.wrapper-preview-content {
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 0.72rem;
line-height: 1.5;
padding: 12px;
max-height: 300px;
overflow: auto;
white-space: pre;
tab-size: 4;
}
.settings-hint { .settings-hint {
margin-top: 0.5rem; margin-top: 0.5rem;
font-size: 0.8rem; font-size: 0.8rem;

View File

@@ -858,6 +858,212 @@ function checkLaunchReady() {
}; };
} }
// =============================================================================
// JAVA WRAPPER CONFIGURATION (Structured)
// =============================================================================
const DEFAULT_WRAPPER_CONFIG = {
stripFlags: ['-XX:+UseCompactObjectHeaders'],
injectArgs: [
{ arg: '--disable-sentry', condition: 'server' }
]
};
function getDefaultWrapperConfig() {
return JSON.parse(JSON.stringify(DEFAULT_WRAPPER_CONFIG));
}
function loadWrapperConfig() {
const config = loadConfig();
if (config.javaWrapperConfig && typeof config.javaWrapperConfig === 'object') {
const wc = config.javaWrapperConfig;
if (Array.isArray(wc.stripFlags) && Array.isArray(wc.injectArgs)) {
const loaded = JSON.parse(JSON.stringify(wc));
// Normalize entries: ensure every injectArg has a valid condition
for (const entry of loaded.injectArgs) {
if (!['server', 'always'].includes(entry.condition)) {
entry.condition = 'always';
}
}
return loaded;
}
}
return getDefaultWrapperConfig();
}
function saveWrapperConfig(wrapperConfig) {
if (!wrapperConfig || typeof wrapperConfig !== 'object') {
throw new Error('Invalid wrapper config');
}
if (!Array.isArray(wrapperConfig.stripFlags) || !Array.isArray(wrapperConfig.injectArgs)) {
throw new Error('Invalid wrapper config structure');
}
// Validate injectArgs entries
for (const entry of wrapperConfig.injectArgs) {
if (!entry.arg || typeof entry.arg !== 'string') {
throw new Error('Each inject arg must have a string "arg" property');
}
if (!['server', 'always'].includes(entry.condition)) {
throw new Error('Inject arg condition must be "server" or "always"');
}
}
saveConfig({ javaWrapperConfig: wrapperConfig });
console.log('[Config] Wrapper config saved');
}
function resetWrapperConfig() {
const config = loadConfig();
delete config.javaWrapperConfig;
delete config.javaWrapperScripts; // Clean up legacy key if present
// Write the cleaned config using the same atomic pattern as saveConfig.
// We cannot use saveConfig() here because it merges (spread) which cannot remove keys.
const data = JSON.stringify(config, null, 2);
fs.writeFileSync(CONFIG_TEMP, data, 'utf8');
if (fs.existsSync(CONFIG_FILE)) {
fs.copyFileSync(CONFIG_FILE, CONFIG_BACKUP);
}
fs.renameSync(CONFIG_TEMP, CONFIG_FILE);
console.log('[Config] Wrapper config reset to default');
return getDefaultWrapperConfig();
}
/**
* Generate a platform-specific wrapper script from structured config
* @param {Object} config - { stripFlags: string[], injectArgs: {arg, condition}[] }
* @param {string} platform - 'darwin', 'win32', or 'linux'
* @param {string|null} javaBin - Path to real java binary (required for darwin/linux)
* @returns {string} Generated script content
*/
function generateWrapperScript(config, platform, javaBin) {
const { stripFlags, injectArgs } = config;
const alwaysArgs = injectArgs.filter(a => a.condition === 'always');
const serverArgs = injectArgs.filter(a => a.condition === 'server');
if (platform === 'win32') {
return _generateWindowsWrapper(stripFlags, alwaysArgs, serverArgs);
} else {
return _generateUnixWrapper(stripFlags, alwaysArgs, serverArgs, javaBin);
}
}
function _generateUnixWrapper(stripFlags, alwaysArgs, serverArgs, javaBin) {
const lines = [
'#!/bin/bash',
'# Java wrapper - generated by HytaleF2P launcher',
`REAL_JAVA="${javaBin || '${JAVA_BIN}'}"`,
'ARGS=("$@")',
''
];
// Strip flags
if (stripFlags.length > 0) {
lines.push('# Strip JVM flags');
lines.push('FILTERED_ARGS=()');
lines.push('for arg in "${ARGS[@]}"; do');
lines.push(' case "$arg" in');
for (const flag of stripFlags) {
lines.push(` "${flag}") echo "[Wrapper] Stripped: $arg" ;;`);
}
lines.push(' *) FILTERED_ARGS+=("$arg") ;;');
lines.push(' esac');
lines.push('done');
} else {
lines.push('FILTERED_ARGS=("${ARGS[@]}")');
}
lines.push('');
// Always-inject args
if (alwaysArgs.length > 0) {
lines.push('# Inject args (always)');
for (const a of alwaysArgs) {
lines.push(`FILTERED_ARGS+=("${a.arg}")`);
lines.push(`echo "[Wrapper] Injected ${a.arg}"`);
}
lines.push('');
}
// Server-conditional args (appended after HytaleServer.jar if present)
if (serverArgs.length > 0) {
lines.push('# Inject args (server only)');
lines.push('IS_SERVER=false');
lines.push('for arg in "${FILTERED_ARGS[@]}"; do');
lines.push(' if [[ "$arg" == *"HytaleServer.jar"* ]]; then');
lines.push(' IS_SERVER=true');
lines.push(' break');
lines.push(' fi');
lines.push('done');
lines.push('if [ "$IS_SERVER" = true ]; then');
for (const a of serverArgs) {
lines.push(` FILTERED_ARGS+=("${a.arg}")`);
lines.push(` echo "[Wrapper] Injected ${a.arg}"`);
}
lines.push('fi');
lines.push('');
}
lines.push('echo "[Wrapper] Executing: $REAL_JAVA ${FILTERED_ARGS[*]}"');
lines.push('exec "$REAL_JAVA" "${FILTERED_ARGS[@]}"');
lines.push('');
return lines.join('\n');
}
function _generateWindowsWrapper(stripFlags, alwaysArgs, serverArgs) {
const lines = [
'@echo off',
'setlocal EnableDelayedExpansion',
'',
'REM Java wrapper - generated by HytaleF2P launcher',
'set "REAL_JAVA=%~dp0java-original.exe"',
'set "ARGS=%*"',
''
];
// Strip flags using string replacement
if (stripFlags.length > 0) {
lines.push('REM Strip JVM flags');
for (const flag of stripFlags) {
lines.push(`set "ARGS=!ARGS:${flag}=!"`);
}
lines.push('');
}
// Always-inject args
const alwaysExtra = alwaysArgs.map(a => a.arg).join(' ');
// Server-conditional args
if (serverArgs.length > 0) {
const serverExtra = serverArgs.map(a => a.arg).join(' ');
lines.push('REM Check if running HytaleServer.jar and inject server args');
lines.push('echo !ARGS! | findstr /i "HytaleServer.jar" >nul 2>&1');
lines.push('if "!ERRORLEVEL!"=="0" (');
if (alwaysExtra) {
lines.push(` echo [Wrapper] Injected ${alwaysExtra} ${serverExtra}`);
lines.push(` "%REAL_JAVA%" !ARGS! ${alwaysExtra} ${serverExtra}`);
} else {
lines.push(` echo [Wrapper] Injected ${serverExtra}`);
lines.push(` "%REAL_JAVA%" !ARGS! ${serverExtra}`);
}
lines.push(') else (');
if (alwaysExtra) {
lines.push(` "%REAL_JAVA%" !ARGS! ${alwaysExtra}`);
} else {
lines.push(' "%REAL_JAVA%" !ARGS!');
}
lines.push(')');
} else if (alwaysExtra) {
lines.push(`"%REAL_JAVA%" !ARGS! ${alwaysExtra}`);
} else {
lines.push('"%REAL_JAVA%" !ARGS!');
}
lines.push('exit /b !ERRORLEVEL!');
lines.push('');
return lines.join('\r\n');
}
// ============================================================================= // =============================================================================
// EXPORTS // EXPORTS
// ============================================================================= // =============================================================================
@@ -924,6 +1130,13 @@ module.exports = {
saveVersionBranch, saveVersionBranch,
loadVersionBranch, loadVersionBranch,
// Java Wrapper Config
getDefaultWrapperConfig,
loadWrapperConfig,
saveWrapperConfig,
resetWrapperConfig,
generateWrapperScript,
// Constants // Constants
CONFIG_FILE, CONFIG_FILE,
UUID_STORE_FILE UUID_STORE_FILE

View File

@@ -45,7 +45,13 @@ const {
saveVersionClient, saveVersionClient,
loadVersionClient, loadVersionClient,
saveVersionBranch, saveVersionBranch,
loadVersionBranch loadVersionBranch,
// Java Wrapper Config
getDefaultWrapperConfig,
loadWrapperConfig,
saveWrapperConfig,
resetWrapperConfig,
generateWrapperScript
} = require('./core/config'); } = require('./core/config');
const { getResolvedAppDir, getModsPath } = require('./core/paths'); const { getResolvedAppDir, getModsPath } = require('./core/paths');
@@ -197,6 +203,13 @@ module.exports = {
proposeGameUpdate, proposeGameUpdate,
handleFirstLaunchCheck, handleFirstLaunchCheck,
// Java Wrapper Config functions
getDefaultWrapperConfig,
loadWrapperConfig,
saveWrapperConfig,
resetWrapperConfig,
generateWrapperScript,
// Path functions // Path functions
getResolvedAppDir getResolvedAppDir
}; };

View File

@@ -18,7 +18,9 @@ const {
saveVersionClient, saveVersionClient,
loadUsername, loadUsername,
hasUsername, hasUsername,
checkLaunchReady checkLaunchReady,
loadWrapperConfig,
generateWrapperScript
} = require('../core/config'); } = require('../core/config');
const { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA_EXECUTABLE } = require('./javaManager'); const { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA_EXECUTABLE } = require('./javaManager');
const { getLatestClientVersion } = require('../services/versionManager'); const { getLatestClientVersion } = require('../services/versionManager');
@@ -27,6 +29,7 @@ const { ensureGameInstalled } = require('./differentialUpdateManager');
const { syncModsForCurrentProfile } = require('./modManager'); 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');
// Client patcher for custom auth server (sanasol.ws) // Client patcher for custom auth server (sanasol.ws)
let clientPatcher = null; let clientPatcher = null;
@@ -327,23 +330,13 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
console.log('Signed server binaries (after patching)'); console.log('Signed server binaries (after patching)');
} }
// Create java wrapper (must be signed on macOS)
if (javaBin && fs.existsSync(javaBin)) { if (javaBin && fs.existsSync(javaBin)) {
const javaWrapperPath = path.join(path.dirname(javaBin), 'java-wrapper'); const javaWrapperPath = path.join(path.dirname(javaBin), 'java-wrapper');
const wrapperScript = `#!/bin/bash const wrapperScript = generateWrapperScript(loadWrapperConfig(), 'darwin', javaBin);
# Java wrapper for macOS - adds --disable-sentry to fix Sentry hang issue
REAL_JAVA="${javaBin}"
ARGS=("$@")
for i in "\${!ARGS[@]}"; do
if [[ "\${ARGS[$i]}" == *"HytaleServer.jar"* ]]; then
ARGS=("\${ARGS[@]:0:$((i+1))}" "--disable-sentry" "\${ARGS[@]:$((i+1))}")
break
fi
done
exec "$REAL_JAVA" "\${ARGS[@]}"
`;
fs.writeFileSync(javaWrapperPath, wrapperScript, { mode: 0o755 }); fs.writeFileSync(javaWrapperPath, wrapperScript, { mode: 0o755 });
await signPath(javaWrapperPath, false); await signPath(javaWrapperPath, false);
console.log('Created java wrapper with --disable-sentry fix'); console.log('Created java wrapper from config template');
javaBin = javaWrapperPath; javaBin = javaWrapperPath;
} }
} catch (signError) { } catch (signError) {
@@ -352,6 +345,40 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
} }
} }
// Windows: Create java wrapper to strip/inject JVM flags per wrapper config
if (process.platform === 'win32' && javaBin && fs.existsSync(javaBin)) {
try {
const javaDir = path.dirname(javaBin);
const javaOriginal = path.join(javaDir, 'java-original.exe');
const javaWrapperPath = path.join(javaDir, 'java-wrapper.bat');
if (!fs.existsSync(javaOriginal)) {
fs.copyFileSync(javaBin, javaOriginal);
console.log('Backed up java.exe as java-original.exe');
}
const wrapperScript = generateWrapperScript(loadWrapperConfig(), 'win32', null);
fs.writeFileSync(javaWrapperPath, wrapperScript);
console.log('Created Windows java wrapper from config template');
javaBin = javaWrapperPath;
} catch (wrapperError) {
console.log('Notice: Windows java wrapper creation failed:', wrapperError.message);
}
}
// Linux: Create java wrapper to strip/inject JVM flags per wrapper config
if (process.platform === 'linux' && javaBin && fs.existsSync(javaBin)) {
try {
const javaWrapperPath = path.join(path.dirname(javaBin), 'java-wrapper');
const wrapperScript = generateWrapperScript(loadWrapperConfig(), 'linux', javaBin);
fs.writeFileSync(javaWrapperPath, wrapperScript, { mode: 0o755 });
console.log('Created Linux java wrapper from config template');
javaBin = javaWrapperPath;
} catch (wrapperError) {
console.log('Notice: Linux java wrapper creation failed:', wrapperError.message);
}
}
const args = [ const args = [
'--app-dir', gameLatest, '--app-dir', gameLatest,
'--java-exec', javaBin, '--java-exec', javaBin,
@@ -436,6 +463,22 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
} }
} }
// Kill any stalled game processes from a previous launch to prevent file locks
// and "game already running" issues
await killGameProcesses();
// Remove AOT cache: generated by official Hytale JRE, incompatible with F2P JRE.
// Client adds -XX:AOTCache when this file exists, causing classloading failures.
const aotCache = path.join(gameLatest, 'Server', 'HytaleServer.aot');
if (fs.existsSync(aotCache)) {
try {
fs.unlinkSync(aotCache);
console.log('Removed incompatible AOT cache (HytaleServer.aot)');
} catch (aotErr) {
console.warn('Could not remove AOT cache:', aotErr.message);
}
}
// DualAuth Agent: Set JAVA_TOOL_OPTIONS so java picks up -javaagent: flag // DualAuth Agent: Set JAVA_TOOL_OPTIONS so java picks up -javaagent: flag
// This enables runtime auth patching without modifying the server JAR // This enables runtime auth patching without modifying the server JAR
const agentJar = path.join(gameLatest, 'Server', 'dualauth-agent.jar'); const agentJar = path.join(gameLatest, 'Server', 'dualauth-agent.jar');

View File

@@ -39,6 +39,41 @@ async function isGameRunning() {
} }
} }
// Force-kill stalled game processes to release file locks before repair/reinstall.
// Cross-platform: Windows (taskkill/PowerShell), macOS (pkill), Linux (pkill).
async function killGameProcesses() {
const killed = [];
async function tryKill(command, label) {
try {
await execAsync(command);
killed.push(label);
} catch (_) { /* process not found is expected */ }
}
if (process.platform === 'win32') {
// Kill client
await tryKill('taskkill /F /IM "HytaleClient.exe" /T', 'HytaleClient.exe');
// Kill java.exe instances running HytaleServer.jar via PowerShell
// (Get-CimInstance replaces deprecated wmic, works on Windows 10+)
await tryKill(
'powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name=\'java.exe\'\\" | Where-Object { $_.CommandLine -like \'*HytaleServer*\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"',
'java.exe(HytaleServer)'
);
} else {
// macOS and Linux
await tryKill('pkill -9 -f HytaleClient', 'HytaleClient');
await tryKill('pkill -9 -f HytaleServer', 'HytaleServer');
}
if (killed.length > 0) {
console.log(`[GameManager] Force-killed stalled processes: ${killed.join(', ')}`);
// Wait for OS to release file handles
await new Promise(resolve => setTimeout(resolve, 2000));
}
return killed;
}
// Helper function to safely remove directory with retry logic // Helper function to safely remove directory with retry logic
async function safeRemoveDirectory(dirPath, maxRetries = 3) { async function safeRemoveDirectory(dirPath, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
@@ -52,6 +87,11 @@ async function safeRemoveDirectory(dirPath, maxRetries = 3) {
console.warn(`Attempt ${attempt}/${maxRetries} failed to remove ${dirPath}: ${error.message}`); console.warn(`Attempt ${attempt}/${maxRetries} failed to remove ${dirPath}: ${error.message}`);
if (attempt < maxRetries) { if (attempt < maxRetries) {
// On EPERM/EBUSY, try killing stalled game processes that hold file locks
if (attempt === 1 && (error.code === 'EPERM' || error.code === 'EBUSY')) {
console.log('Permission error detected, killing stalled game processes...');
await killGameProcesses();
}
// Wait before retrying (exponential backoff) // Wait before retrying (exponential backoff)
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000); const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
console.log(`Waiting ${delay}ms before retry...`); console.log(`Waiting ${delay}ms before retry...`);
@@ -833,11 +873,14 @@ async function repairGame(progressCallback, branchOverride = null) {
progressCallback('Removing old game files...', 30, null, null, null); progressCallback('Removing old game files...', 30, null, null, null);
} }
// Check if game is running before attempting to delete files // Kill stalled game processes before attempting to delete files
const gameRunning = await isGameRunning(); const gameRunning = await isGameRunning();
if (gameRunning) { if (gameRunning) {
console.warn('[RepairGame] Game appears to be running. This may cause permission errors during repair.'); console.warn('[RepairGame] Game processes detected. Force-killing to release file locks...');
console.log('[RepairGame] Please close the game before repairing, or wait for the repair to complete.'); if (progressCallback) {
progressCallback('Stopping stalled game processes...', 20, null, null, null);
}
await killGameProcesses();
} }
// Delete Game and Cache Directory with retry logic // Delete Game and Cache Directory with retry logic
@@ -964,5 +1007,6 @@ module.exports = {
installGame, installGame,
uninstallGame, uninstallGame,
checkExistingGameInstallation, checkExistingGameInstallation,
repairGame repairGame,
killGameProcesses
}; };

View File

@@ -7,16 +7,17 @@ const { getOS, getArch } = require('../utils/platformUtils');
// Patches base URL fetched dynamically via multi-source fallback chain // Patches base URL fetched dynamically via multi-source fallback chain
const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws'; const AUTH_DOMAIN = process.env.HYTALE_AUTH_DOMAIN || 'auth.sanasol.ws';
const PATCHES_CONFIG_SOURCES = [ const PATCHES_CONFIG_SOURCES = [
{ type: 'http', url: `https://${AUTH_DOMAIN}/api/patches-config`, name: 'auth-server' }, { type: 'http', url: `https://${AUTH_DOMAIN}/api/patches-config`, name: 'primary' },
{ type: 'http', url: 'https://htdwnldsan.top/patches-config', name: 'backup-http' }, { type: 'http', url: 'https://htdwnldsan.top/patches-config', name: 'backup-1' },
{ type: 'http', url: 'https://dl1.htdwnldsan.top/patches-config', name: 'backup-2' },
{ type: 'doh', name: '_patches.htdwnldsan.top', name_label: 'dns-txt' }, { type: 'doh', name: '_patches.htdwnldsan.top', name_label: 'dns-txt' },
]; ];
const HARDCODED_FALLBACK = 'https://dl.vboro.de/patches'; const HARDCODED_FALLBACK = 'https://dl.vboro.de/patches';
// Non-Cloudflare mirrors for users where Cloudflare IPs are blocked (Russia, Ukraine, etc.) // Alternative mirrors (non-Cloudflare) for regions where CF is blocked
// These redirect to MEGA S3 which is not behind Cloudflare
const NON_CF_MIRRORS = [ const NON_CF_MIRRORS = [
'https://htdwnldsan.top/patches', // Direct IP VPS → MEGA redirect 'https://dl1.htdwnldsan.top',
'https://htdwnldsan.top/patches',
]; ];
// Fallback: latest known build number if manifest is unreachable // Fallback: latest known build number if manifest is unreachable

View File

@@ -9,7 +9,9 @@ const MAX_DOMAIN_LENGTH = 16;
// DualAuth ByteBuddy Agent (runtime class transformation, no JAR modification) // DualAuth ByteBuddy Agent (runtime class transformation, no JAR modification)
const DUALAUTH_AGENT_URL = 'https://github.com/sanasol/hytale-auth-server/releases/latest/download/dualauth-agent.jar'; const DUALAUTH_AGENT_URL = 'https://github.com/sanasol/hytale-auth-server/releases/latest/download/dualauth-agent.jar';
const DUALAUTH_AGENT_VERSION_API = 'https://api.github.com/repos/sanasol/hytale-auth-server/releases/latest';
const DUALAUTH_AGENT_FILENAME = 'dualauth-agent.jar'; const DUALAUTH_AGENT_FILENAME = 'dualauth-agent.jar';
const DUALAUTH_AGENT_VERSION_FILE = 'dualauth-agent.version';
function getTargetDomain() { function getTargetDomain() {
if (process.env.HYTALE_AUTH_DOMAIN) { if (process.env.HYTALE_AUTH_DOMAIN) {
@@ -511,30 +513,70 @@ class ClientPatcher {
*/ */
async ensureAgentAvailable(serverDir, progressCallback) { async ensureAgentAvailable(serverDir, progressCallback) {
const agentPath = this.getAgentPath(serverDir); const agentPath = this.getAgentPath(serverDir);
const versionPath = path.join(serverDir, DUALAUTH_AGENT_VERSION_FILE);
console.log('=== DualAuth Agent (ByteBuddy) ==='); console.log('=== DualAuth Agent (ByteBuddy) ===');
console.log(`Target: ${agentPath}`); console.log(`Target: ${agentPath}`);
// Check if agent already exists and is valid // Check local version and whether file exists
let localVersion = null;
let agentExists = false;
if (fs.existsSync(agentPath)) { if (fs.existsSync(agentPath)) {
try { try {
const stats = fs.statSync(agentPath); const stats = fs.statSync(agentPath);
if (stats.size > 1024) { if (stats.size > 1024) {
console.log(`DualAuth Agent present (${(stats.size / 1024).toFixed(0)} KB)`); agentExists = true;
if (progressCallback) progressCallback('DualAuth Agent ready', 100); if (fs.existsSync(versionPath)) {
return { success: true, agentPath, alreadyExists: true }; localVersion = fs.readFileSync(versionPath, 'utf8').trim();
}
} else {
console.log('Agent file appears corrupt, re-downloading...');
fs.unlinkSync(agentPath);
} }
// File exists but too small - corrupt, re-download
console.log('Agent file appears corrupt, re-downloading...');
fs.unlinkSync(agentPath);
} catch (e) { } catch (e) {
console.warn('Could not check agent file:', e.message); console.warn('Could not check agent file:', e.message);
} }
} }
// Check for updates from GitHub
let remoteVersion = null;
let needsDownload = !agentExists;
if (agentExists) {
try {
if (progressCallback) progressCallback('Checking for agent updates...', 5);
const axios = require('axios');
const resp = await axios.get(DUALAUTH_AGENT_VERSION_API, {
timeout: 5000,
headers: { 'Accept': 'application/vnd.github.v3+json' }
});
remoteVersion = resp.data.tag_name; // e.g. "v1.1.10"
if (localVersion && localVersion === remoteVersion) {
console.log(`DualAuth Agent up to date (${localVersion})`);
if (progressCallback) progressCallback('DualAuth Agent ready', 100);
return { success: true, agentPath, alreadyExists: true, version: localVersion };
}
console.log(`Agent update available: ${localVersion || 'unknown'}${remoteVersion}`);
needsDownload = true;
} catch (e) {
// GitHub API failed - use existing agent if available
console.warn(`Could not check for updates: ${e.message}`);
if (agentExists) {
console.log(`Using existing agent (${localVersion || 'unknown version'})`);
if (progressCallback) progressCallback('DualAuth Agent ready', 100);
return { success: true, agentPath, alreadyExists: true, version: localVersion };
}
}
}
if (!needsDownload) {
if (progressCallback) progressCallback('DualAuth Agent ready', 100);
return { success: true, agentPath, alreadyExists: true, version: localVersion };
}
// Download agent from GitHub releases // Download agent from GitHub releases
if (progressCallback) progressCallback('Downloading DualAuth Agent...', 20); const action = agentExists ? 'Updating' : 'Downloading';
console.log(`Downloading from: ${DUALAUTH_AGENT_URL}`); if (progressCallback) progressCallback(`${action} DualAuth Agent...`, 20);
console.log(`${action} from: ${DUALAUTH_AGENT_URL}`);
try { try {
// Ensure server directory exists // Ensure server directory exists
@@ -548,7 +590,7 @@ class ClientPatcher {
const stream = await smartDownloadStream(DUALAUTH_AGENT_URL, (chunk, downloadedBytes, total) => { const stream = await smartDownloadStream(DUALAUTH_AGENT_URL, (chunk, downloadedBytes, total) => {
if (progressCallback && total) { if (progressCallback && total) {
const percent = 20 + Math.floor((downloadedBytes / total) * 70); const percent = 20 + Math.floor((downloadedBytes / total) * 70);
progressCallback(`Downloading agent... ${(downloadedBytes / 1024).toFixed(0)} KB`, percent); progressCallback(`${action} agent... ${(downloadedBytes / 1024).toFixed(0)} KB`, percent);
} }
}); });
@@ -575,9 +617,13 @@ class ClientPatcher {
} }
fs.renameSync(tmpPath, agentPath); fs.renameSync(tmpPath, agentPath);
console.log(`DualAuth Agent downloaded (${(stats.size / 1024).toFixed(0)} KB)`); // Save version
const version = remoteVersion || 'unknown';
fs.writeFileSync(versionPath, version, 'utf8');
console.log(`DualAuth Agent ${agentExists ? 'updated' : 'downloaded'} (${(stats.size / 1024).toFixed(0)} KB, ${version})`);
if (progressCallback) progressCallback('DualAuth Agent ready', 100); if (progressCallback) progressCallback('DualAuth Agent ready', 100);
return { success: true, agentPath }; return { success: true, agentPath, updated: agentExists, version };
} catch (downloadError) { } catch (downloadError) {
console.error(`Failed to download DualAuth Agent: ${downloadError.message}`); console.error(`Failed to download DualAuth Agent: ${downloadError.message}`);
@@ -586,6 +632,11 @@ class ClientPatcher {
if (fs.existsSync(tmpPath)) { if (fs.existsSync(tmpPath)) {
try { fs.unlinkSync(tmpPath); } catch (e) { /* ignore */ } try { fs.unlinkSync(tmpPath); } catch (e) { /* ignore */ }
} }
// If we had an existing agent, still use it
if (agentExists) {
console.log('Using existing agent despite update failure');
return { success: true, agentPath, alreadyExists: true, version: localVersion };
}
return { success: false, error: downloadError.message }; return { success: false, error: downloadError.message };
} }
} }

View File

@@ -0,0 +1,238 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const zlib = require('zlib');
const logger = require('../logger');
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB per file
/**
* Get the HytaleSaves directory (game client logs)
*/
function getHytaleSavesDir() {
const home = os.homedir();
if (process.platform === 'win32') {
return path.join(home, 'AppData', 'Local', 'HytaleSaves');
} else if (process.platform === 'darwin') {
return path.join(home, 'Library', 'Application Support', 'HytaleSaves');
} else {
return path.join(home, '.hytalesaves');
}
}
/**
* Read a log file, capping at MAX_FILE_SIZE (keeps tail/most recent lines)
*/
function readLogFile(filePath) {
try {
const stats = fs.statSync(filePath);
if (stats.size <= MAX_FILE_SIZE) {
return fs.readFileSync(filePath, 'utf8');
}
// Read only the last MAX_FILE_SIZE bytes
const fd = fs.openSync(filePath, 'r');
const buffer = Buffer.alloc(MAX_FILE_SIZE);
fs.readSync(fd, buffer, 0, MAX_FILE_SIZE, stats.size - MAX_FILE_SIZE);
fs.closeSync(fd);
const content = buffer.toString('utf8');
// Skip first partial line
const firstNewline = content.indexOf('\n');
const trimmed = firstNewline >= 0 ? content.substring(firstNewline + 1) : content;
return `[... truncated ${stats.size - MAX_FILE_SIZE} bytes ...]\n` + trimmed;
} catch (err) {
return `[Error reading file: ${err.message}]`;
}
}
/**
* Get files matching a date pattern from a directory
*/
function getFilesForDate(dir, dateStr, pattern) {
if (!fs.existsSync(dir)) return [];
try {
return fs.readdirSync(dir)
.filter(f => f.includes(dateStr) && (pattern ? pattern.test(f) : true))
.map(f => ({
name: f,
path: path.join(dir, f),
mtime: fs.statSync(path.join(dir, f)).mtime
}))
.sort((a, b) => b.mtime - a.mtime);
} catch (err) {
return [];
}
}
// ============================================================================
// ZIP BUILDER (pure Node.js, no dependencies)
// ============================================================================
/**
* CRC32 lookup table
*/
const crc32Table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
crc32Table[i] = c;
}
function crc32(buf) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) {
crc = crc32Table[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
/**
* Create a ZIP file from an array of {name, content} entries
* Uses DEFLATE compression via built-in zlib
*/
function createZipBuffer(files) {
const localHeaders = [];
const centralEntries = [];
let offset = 0;
for (const file of files) {
const nameBytes = Buffer.from(file.name, 'utf8');
const contentBytes = Buffer.from(file.content, 'utf8');
const compressed = zlib.deflateRawSync(contentBytes);
const crcVal = crc32(contentBytes);
// Local file header (30 bytes + filename)
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0); // signature
local.writeUInt16LE(20, 4); // version needed
local.writeUInt16LE(0, 6); // flags
local.writeUInt16LE(8, 8); // compression: DEFLATE
local.writeUInt16LE(0, 10); // mod time
local.writeUInt16LE(0, 12); // mod date
local.writeUInt32LE(crcVal, 14); // crc32
local.writeUInt32LE(compressed.length, 18); // compressed size
local.writeUInt32LE(contentBytes.length, 22); // uncompressed size
local.writeUInt16LE(nameBytes.length, 26); // filename length
local.writeUInt16LE(0, 28); // extra field length
localHeaders.push(local, nameBytes, compressed);
// Central directory entry (46 bytes + filename)
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0); // signature
central.writeUInt16LE(20, 4); // version made by
central.writeUInt16LE(20, 6); // version needed
central.writeUInt16LE(0, 8); // flags
central.writeUInt16LE(8, 10); // compression
central.writeUInt16LE(0, 12); // mod time
central.writeUInt16LE(0, 14); // mod date
central.writeUInt32LE(crcVal, 16); // crc32
central.writeUInt32LE(compressed.length, 20); // compressed size
central.writeUInt32LE(contentBytes.length, 24); // uncompressed size
central.writeUInt16LE(nameBytes.length, 28); // filename length
central.writeUInt16LE(0, 30); // extra field length
central.writeUInt16LE(0, 32); // comment length
central.writeUInt16LE(0, 34); // disk number
central.writeUInt16LE(0, 36); // internal attrs
central.writeUInt32LE(0, 38); // external attrs
central.writeUInt32LE(offset, 42); // local header offset
centralEntries.push(central, nameBytes);
offset += 30 + nameBytes.length + compressed.length;
}
const centralDirBuf = Buffer.concat(centralEntries);
// End of central directory (22 bytes)
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0); // signature
eocd.writeUInt16LE(0, 4); // disk number
eocd.writeUInt16LE(0, 6); // cd disk number
eocd.writeUInt16LE(files.length, 8); // entries on disk
eocd.writeUInt16LE(files.length, 10); // total entries
eocd.writeUInt32LE(centralDirBuf.length, 12); // cd size
eocd.writeUInt32LE(offset, 16); // cd offset
eocd.writeUInt16LE(0, 20); // comment length
return Buffer.concat([...localHeaders, centralDirBuf, eocd]);
}
// ============================================================================
/**
* Collect all relevant logs for submission
* Returns { files: [{name, content}], meta: {username, platform, version} }
*/
function collectLogs() {
const files = [];
const today = new Date();
const todayStr = today.toISOString().split('T')[0]; // YYYY-MM-DD
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().split('T')[0];
// 1. Launcher logs
const launcherLogDir = logger.getLogDirectory();
if (launcherLogDir && fs.existsSync(launcherLogDir)) {
// Today's launcher logs
const todayLogs = getFilesForDate(launcherLogDir, todayStr, /^launcher-.*\.log$/);
for (const f of todayLogs) {
files.push({ name: `launcher/${f.name}`, content: readLogFile(f.path) });
}
// Most recent from yesterday (just one)
const yesterdayLogs = getFilesForDate(launcherLogDir, yesterdayStr, /^launcher-.*\.log$/);
if (yesterdayLogs.length > 0) {
files.push({ name: `launcher/${yesterdayLogs[0].name}`, content: readLogFile(yesterdayLogs[0].path) });
}
}
// 2. Game client logs
const savesDir = getHytaleSavesDir();
const clientLogDir = path.join(savesDir, 'Logs');
if (fs.existsSync(clientLogDir)) {
const clientLogs = getFilesForDate(clientLogDir, todayStr, /client\.log$/);
for (const f of clientLogs) {
files.push({ name: `client/${f.name}`, content: readLogFile(f.path) });
}
}
// 3. Config snapshot
const appDir = logger.getAppDir ? logger.getAppDir() : logger.getInstallPath();
const configPath = path.join(appDir, 'config.json');
if (fs.existsSync(configPath)) {
try {
const configContent = fs.readFileSync(configPath, 'utf8');
files.push({ name: 'config.json', content: configContent });
} catch (err) {
files.push({ name: 'config.json', content: `[Error reading config: ${err.message}]` });
}
}
// Build metadata
const { app } = require('electron');
let username = 'unknown';
try {
const configFile = path.join(appDir, 'config.json');
if (fs.existsSync(configFile)) {
const cfg = JSON.parse(fs.readFileSync(configFile, 'utf8'));
username = cfg.username || cfg.playerName || 'unknown';
}
} catch (err) {}
return {
files,
meta: {
username,
platform: `${process.platform}-${process.arch}`,
version: app.getVersion()
}
};
}
module.exports = { collectLogs, createZipBuffer };

View File

@@ -164,8 +164,14 @@ function detectGpuLinux() {
const isAmd = lowerLine.includes('amd') || lowerLine.includes('radeon') || vendorId === '1002'; const isAmd = lowerLine.includes('amd') || lowerLine.includes('radeon') || vendorId === '1002';
const isIntel = lowerLine.includes('intel') || vendorId === '8086'; const isIntel = lowerLine.includes('intel') || vendorId === '8086';
// Intel Arc detection // Intel Arc discrete GPU detection
const isIntelArc = isIntel && (lowerName.includes('arc') || lowerName.includes('a770') || lowerName.includes('a750') || lowerName.includes('a380')); // Discrete Arc cards have specific model numbers: A310, A380, A580, A750, A770, B570, B580
// Integrated "Intel Arc Graphics" (Meteor Lake, Lunar Lake, Arrow Lake) have NO model suffix
// and sit on bus 00 (slot 00:02.0) — these are iGPUs, not dGPUs
const pciSlot = line.split(' ')[0] || '';
const pciBus = parseInt(pciSlot.split(':')[0], 16) || 0;
const hasArcModelNumber = isIntel && /\b[ab]\d{3}\b/i.test(lowerName);
const isIntelArc = isIntel && (hasArcModelNumber || (lowerName.includes('arc') && pciBus > 0));
let vendor = 'unknown'; let vendor = 'unknown';
if (isNvidia) vendor = 'nvidia'; if (isNvidia) vendor = 'nvidia';

119
main.js
View File

@@ -1407,6 +1407,83 @@ ipcMain.handle('get-recent-logs', async (event, maxLines = 100) => {
ipcMain.handle('send-logs', async () => {
try {
const https = require('https');
const http = require('http');
const { collectLogs, createZipBuffer } = require('./backend/utils/logCollector');
const { files, meta } = collectLogs();
if (files.length === 0) {
return { success: false, error: 'No log files found' };
}
// Create ZIP with individual log files
const zipBuffer = createZipBuffer(files);
// Get auth server URL from core config
const { getAuthServerUrl } = require('./backend/core/config');
const authUrl = getAuthServerUrl();
// Build file names list
const fileNames = files.map(f => f.name).join(',');
return await new Promise((resolve) => {
const url = new URL(authUrl + '/logs/submit');
const transport = url.protocol === 'https:' ? https : http;
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/zip',
'Content-Length': zipBuffer.length,
'X-Log-Username': meta.username || 'unknown',
'X-Log-Platform': meta.platform || 'unknown',
'X-Log-Version': meta.version || 'unknown',
'X-Log-File-Count': String(files.length),
'X-Log-Files': fileNames
},
timeout: 30000
};
const req = transport.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
try {
const data = JSON.parse(body);
if (res.statusCode === 200) {
resolve({ success: true, id: data.id, message: data.message });
} else {
resolve({ success: false, error: data.error || `Server error ${res.statusCode}` });
}
} catch (e) {
resolve({ success: false, error: `Invalid response: ${res.statusCode}` });
}
});
});
req.on('error', (err) => {
resolve({ success: false, error: err.message });
});
req.on('timeout', () => {
req.destroy();
resolve({ success: false, error: 'Request timed out' });
});
req.write(zipBuffer);
req.end();
});
} catch (error) {
console.error('Error sending logs:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('open-logs-folder', async () => { ipcMain.handle('open-logs-folder', async () => {
try { try {
const logDir = logger.getLogDirectory(); const logDir = logger.getLogDirectory();
@@ -1462,3 +1539,45 @@ ipcMain.handle('profile-update', async (event, id, updates) => {
} }
}); });
// Java Wrapper Config IPC
ipcMain.handle('load-wrapper-config', () => {
const { loadWrapperConfig } = require('./backend/launcher');
return loadWrapperConfig();
});
ipcMain.handle('save-wrapper-config', (event, config) => {
try {
const { saveWrapperConfig } = require('./backend/launcher');
saveWrapperConfig(config);
return { success: true };
} catch (error) {
console.error('Error saving wrapper config:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('reset-wrapper-config', () => {
try {
const { resetWrapperConfig } = require('./backend/launcher');
const config = resetWrapperConfig();
return { success: true, config };
} catch (error) {
console.error('Error resetting wrapper config:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('get-default-wrapper-config', () => {
const { getDefaultWrapperConfig } = require('./backend/launcher');
return getDefaultWrapperConfig();
});
ipcMain.handle('preview-wrapper-script', (event, config, platform) => {
const { generateWrapperScript } = require('./backend/launcher');
return generateWrapperScript(config || require('./backend/launcher').loadWrapperConfig(), platform || process.platform, '/path/to/java');
});
ipcMain.handle('get-current-platform', () => {
return process.platform;
});

View File

@@ -1,6 +1,6 @@
{ {
"name": "hytale-f2p-launcher", "name": "hytale-f2p-launcher",
"version": "2.3.7", "version": "2.4.1",
"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

@@ -94,6 +94,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getLogDirectory: () => ipcRenderer.invoke('get-log-directory'), getLogDirectory: () => ipcRenderer.invoke('get-log-directory'),
openLogsFolder: () => ipcRenderer.invoke('open-logs-folder'), openLogsFolder: () => ipcRenderer.invoke('open-logs-folder'),
getRecentLogs: (maxLines) => ipcRenderer.invoke('get-recent-logs', maxLines), getRecentLogs: (maxLines) => ipcRenderer.invoke('get-recent-logs', maxLines),
sendLogs: () => ipcRenderer.invoke('send-logs'),
// UUID Management methods // UUID Management methods
getCurrentUuid: () => ipcRenderer.invoke('get-current-uuid'), getCurrentUuid: () => ipcRenderer.invoke('get-current-uuid'),
@@ -103,6 +104,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
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'),
// Java Wrapper Config API
loadWrapperConfig: () => ipcRenderer.invoke('load-wrapper-config'),
saveWrapperConfig: (config) => ipcRenderer.invoke('save-wrapper-config', config),
resetWrapperConfig: () => ipcRenderer.invoke('reset-wrapper-config'),
getDefaultWrapperConfig: () => ipcRenderer.invoke('get-default-wrapper-config'),
previewWrapperScript: (config, platform) => ipcRenderer.invoke('preview-wrapper-script', config, platform),
getCurrentPlatform: () => ipcRenderer.invoke('get-current-platform'),
// Profile API // Profile API
profile: { profile: {
create: (name) => ipcRenderer.invoke('profile-create', name), create: (name) => ipcRenderer.invoke('profile-create', name),

133
server/README.md Normal file
View File

@@ -0,0 +1,133 @@
# Hytale F2P - Dedicated Server
Host your own Hytale server. The scripts handle everything automatically.
## Prerequisites
- **Java 25+** — [Windows installer](https://download.oracle.com/java/25/latest/jdk-25_windows-x64_bin.exe) | [Other platforms](https://adoptium.net/)
- If you have the F2P launcher installed, its bundled Java will be used automatically
- **Internet connection** for first launch (downloads ~3.5 GB of game files)
- If you have the F2P launcher installed, game files are copied locally (no download needed)
## Video Guide
[![Video Guide](https://img.youtube.com/vi/KvuXLH7SKvI/maxresdefault.jpg)](https://youtu.be/KvuXLH7SKvI)
## Quick Start
### Free Hosted Server (no PC required)
Use [play.hosting](https://play.hosting) to get a free Hytale server with F2P support:
1. Register at [play.hosting](https://play.hosting)
2. Create a **Hytale** server
3. Start the server once and wait for it to fully load
4. Go to **Files** → open the `mods` folder
5. Click **New****File via URL**
6. Paste: `https://github.com/sanasol/hytale-auth-server/releases/latest/download/dualauth-agent.jar`
7. Click **Query** and download the file
8. Go to **Console** and **Restart** the server
Done — your F2P server is ready to join.
### Windows (self-hosted)
1. Download `start.bat` to an empty folder
2. Double-click `start.bat`
3. Done — server starts on port **5520**
### Linux / macOS (self-hosted)
```bash
mkdir hytale-server && cd hytale-server
curl -O https://git.sanhost.net/sanasol/hytale-f2p/raw/branch/develop/server/start.sh
chmod +x start.sh
./start.sh
```
## What the scripts do
1. Search for the F2P launcher install (default paths + custom `installPath` from config)
2. Use bundled Java from the launcher, or fall back to system Java (25+ required)
3. Copy game files from the launcher install if available
4. Download missing files: `HytaleServer.jar` (~150 MB), `Assets.zip` (~3.3 GB), `dualauth-agent.jar` (~5 MB)
5. Check for updates on every launch (server, assets, and agent)
6. Generate a persistent server ID
7. Fetch authentication tokens
8. Start the server with dual-auth support
## Connecting
- **Same PC**: Connect to `localhost:5520` or `127.0.0.1:5520`
- **LAN**: Connect to your local IP (e.g. `192.168.1.x:5520`)
- **Internet**: Forward port `5520` (TCP + UDP) on your router, friends connect to your public IP
### No public IP? Use playit.gg (recommended)
If you're behind CGNAT or can't port forward, [playit.gg](https://playit.gg) gives you a public address for free:
1. Go to [playit.gg](https://playit.gg) and create an account
2. Download and run the playit agent
3. Create a tunnel — select **Hytale** as the game type, local port `5520`
4. Share the generated address with friends (e.g. `something.joinplayit.gg:12345`)
### Other options
- [Radmin VPN](https://www.radmin-vpn.com/) — virtual LAN, all players must install it
- [ZeroTier](https://www.zerotier.com/) — same idea, create a network, friends join and connect via VPN IP
## Configuration
Set environment variables before running the script:
| Variable | Default | Description |
|----------|---------|-------------|
| `SERVER_NAME` | `My Hytale Server` | Server name shown in listings |
| `BIND_ADDRESS` | `0.0.0.0:5520` | IP and port to listen on |
| `JVM_XMX` | *(Java default)* | Max memory (e.g. `4G`, `8G`) |
| `JVM_XMS` | *(Java default)* | Initial memory |
| `AUTH_MODE` | `authenticated` | Auth mode (`authenticated` or `none`) |
| `HYTALE_AUTH_DOMAIN` | `auth.sanasol.ws` | Auth server domain |
| `DOWNLOAD_BASE` | `https://download.sanasol.ws/download` | File download URL |
**Example (Linux):**
```bash
SERVER_NAME="Epic Server" JVM_XMX=4G ./start.sh
```
**Example (Windows):**
```cmd
set SERVER_NAME=Epic Server
set JVM_XMX=4G
start.bat
```
## Files created
```
your-folder/
├── start.sh / start.bat # Startup script
├── HytaleServer.jar # Game server (auto-downloaded)
├── Assets.zip # Game assets (auto-downloaded)
├── dualauth-agent.jar # Auth agent (auto-downloaded)
├── .server-id # Persistent server UUID
├── .versions/ # Version tracking for auto-updates
├── Server/ # Server data (created by server)
│ ├── config.json
│ └── worlds/
└── UserData/ # Player saves
```
## Troubleshooting
| Problem | Solution |
|---------|----------|
| `java not found` | Install the F2P launcher (includes Java) or install Java 25+ from [adoptium.net](https://adoptium.net/) |
| Download fails | Check internet connection. Files can be downloaded manually from `https://download.sanasol.ws/download/` |
| Port already in use | Change port: `BIND_ADDRESS=0.0.0.0:5521 ./start.sh` |
| Out of memory | Set more RAM: `JVM_XMX=4G ./start.sh` |
| Friends can't connect | Forward port 5520 (TCP+UDP) on your router, or use [playit.gg](https://playit.gg) if you can't port forward |
## Discord
Need help? Join the community: https://discord.gg/Fhbb9Yk5WW

441
server/start.bat Normal file
View File

@@ -0,0 +1,441 @@
@echo off
setlocal enabledelayedexpansion
:: ============================================================
:: Hytale F2P Dedicated Server - One-Click Starter
:: ============================================================
:: Just double-click this file to start your server!
::
:: The script will:
:: 1. Look for game files and Java in your F2P launcher install
:: 2. Auto-download anything missing
:: 3. Auto-update server, assets, and agent on each launch
:: 4. Fetch auth tokens and start the server
:: ============================================================
:: Configuration (edit these or set as environment variables)
if not defined HYTALE_AUTH_DOMAIN set "HYTALE_AUTH_DOMAIN=auth.sanasol.ws"
if not defined AUTH_SERVER set "AUTH_SERVER=https://%HYTALE_AUTH_DOMAIN%"
if not defined SERVER_NAME set "SERVER_NAME=My Hytale Server"
if not defined ASSETS_PATH set "ASSETS_PATH=.\Assets.zip"
if not defined BIND_ADDRESS set "BIND_ADDRESS=0.0.0.0:5520"
if not defined AUTH_MODE set "AUTH_MODE=authenticated"
if not defined DOWNLOAD_BASE set "DOWNLOAD_BASE=https://download.sanasol.ws/download"
:: File names
set "AGENT_JAR=dualauth-agent.jar"
set "SERVER_JAR=HytaleServer.jar"
set "AGENT_URL=https://github.com/sanasol/hytale-auth-server/releases/latest/download/dualauth-agent.jar"
set "AGENT_VERSION_API=https://api.github.com/repos/sanasol/hytale-auth-server/releases/latest"
set "VERSION_DIR=.versions"
echo ============================================================
echo Hytale F2P Dedicated Server
echo ============================================================
echo.
:: --- Prerequisite Checks ---
where curl >nul 2>&1
if errorlevel 1 (
echo [ERROR] curl is required but not found
echo [ERROR] curl comes with Windows 10+. Update Windows or install curl.
pause
exit /b 1
)
if not exist "%VERSION_DIR%" mkdir "%VERSION_DIR%"
:: --- Find Local F2P Launcher Install ---
set "F2P_DIR="
set "F2P_BASE=%USERPROFILE%\AppData\Local\HytaleF2P"
set "F2P_CONFIG=%F2P_BASE%\config.json"
set "JAVA_CMD=java"
:: Check config.json for custom installPath
set "F2P_CUSTOM_BASE="
if exist "%F2P_CONFIG%" (
for /f "delims=" %%p in ('powershell -Command "try { $c = Get-Content '%F2P_CONFIG%' | ConvertFrom-Json; if ($c.installPath) { $c.installPath.Trim() + '\HytaleF2P' } } catch {}" 2^>nul') do (
set "F2P_CUSTOM_BASE=%%p"
)
)
:: Search for game files: custom path first, then default
if defined F2P_CUSTOM_BASE (
if exist "!F2P_CUSTOM_BASE!\release\package\game\latest" (
set "F2P_DIR=!F2P_CUSTOM_BASE!\release\package\game\latest"
) else if exist "!F2P_CUSTOM_BASE!\pre-release\package\game\latest" (
set "F2P_DIR=!F2P_CUSTOM_BASE!\pre-release\package\game\latest"
)
)
if not defined F2P_DIR (
if exist "%F2P_BASE%\release\package\game\latest" (
set "F2P_DIR=%F2P_BASE%\release\package\game\latest"
) else if exist "%F2P_BASE%\pre-release\package\game\latest" (
set "F2P_DIR=%F2P_BASE%\pre-release\package\game\latest"
)
)
:: --- Find Java from F2P launcher ---
:: Check config.json for custom javaPath
if exist "%F2P_CONFIG%" (
for /f "delims=" %%j in ('powershell -Command "try { $c = Get-Content '%F2P_CONFIG%' | ConvertFrom-Json; if ($c.javaPath -and (Test-Path $c.javaPath)) { $c.javaPath.Trim() } } catch {}" 2^>nul') do (
set "JAVA_CMD=%%j"
echo [INFO] Found Java in F2P config: %%j
)
)
:: Check bundled JRE if no custom javaPath found
if "!JAVA_CMD!"=="java" (
set "F2P_JRE_BASE="
if defined F2P_CUSTOM_BASE (
if exist "!F2P_CUSTOM_BASE!\release\package\jre\latest\bin\java.exe" (
set "F2P_JRE_BASE=!F2P_CUSTOM_BASE!\release\package\jre\latest"
) else if exist "!F2P_CUSTOM_BASE!\pre-release\package\jre\latest\bin\java.exe" (
set "F2P_JRE_BASE=!F2P_CUSTOM_BASE!\pre-release\package\jre\latest"
)
)
if not defined F2P_JRE_BASE (
if exist "%F2P_BASE%\release\package\jre\latest\bin\java.exe" (
set "F2P_JRE_BASE=%F2P_BASE%\release\package\jre\latest"
) else if exist "%F2P_BASE%\pre-release\package\jre\latest\bin\java.exe" (
set "F2P_JRE_BASE=%F2P_BASE%\pre-release\package\jre\latest"
)
)
if defined F2P_JRE_BASE (
set "JAVA_CMD=!F2P_JRE_BASE!\bin\java.exe"
echo [INFO] Found Java in F2P launcher: !JAVA_CMD!
)
)
:: Verify java exists
"!JAVA_CMD!" -version >nul 2>&1
if errorlevel 1 (
where java >nul 2>&1
if errorlevel 1 (
echo [ERROR] Java is not installed and no F2P launcher JRE found
echo.
echo Options:
echo 1. Install the F2P launcher first ^(it includes Java^)
echo 2. Download Java 25: https://download.oracle.com/java/25/latest/jdk-25_windows-x64_bin.exe
echo.
pause
exit /b 1
)
set "JAVA_CMD=java"
)
:: Check Java version
for /f "tokens=3 delims= " %%v in ('"!JAVA_CMD!" -version 2^>^&1 ^| findstr /i "version"') do (
set "JAVA_VER_RAW=%%~v"
)
if defined JAVA_VER_RAW (
for /f "tokens=1 delims=." %%m in ("!JAVA_VER_RAW!") do set "JAVA_MAJOR=%%m"
)
echo [INFO] Java: !JAVA_VER_RAW! ^(!JAVA_CMD!^)
if defined JAVA_MAJOR (
if !JAVA_MAJOR! LSS 25 (
echo [ERROR] Java !JAVA_MAJOR! detected. Java 25+ is REQUIRED.
echo The DualAuth agent requires Java 25 ^(class file version 69^).
echo.
echo Download Java 25: https://download.oracle.com/java/25/latest/jdk-25_windows-x64_bin.exe
echo.
pause
exit /b 1
)
)
:: --- Copy game files from F2P install ---
if defined F2P_DIR (
echo [INFO] Found F2P launcher game files: !F2P_DIR!
if not exist "%SERVER_JAR%" (
if exist "!F2P_DIR!\Server\HytaleServer.jar" (
echo [INFO] Found HytaleServer.jar in F2P launcher
echo [INFO] Copying from: !F2P_DIR!\Server\HytaleServer.jar
copy "!F2P_DIR!\Server\HytaleServer.jar" "%SERVER_JAR%" >nul
echo [INFO] Copied successfully
)
)
if not exist "%ASSETS_PATH%" (
if exist "!F2P_DIR!\Assets.zip" (
echo [INFO] Found Assets.zip in F2P launcher
echo [INFO] Copying from: !F2P_DIR!\Assets.zip
copy "!F2P_DIR!\Assets.zip" "%ASSETS_PATH%" >nul
echo [INFO] Copied successfully
)
)
echo.
) else (
echo [INFO] No F2P launcher install found, will download files
echo.
)
:: --- Download / Update HytaleServer.jar ---
set "JAR_URL=%DOWNLOAD_BASE%/HytaleServer.jar"
set "JAR_VERSION_FILE=%VERSION_DIR%\HytaleServer.jar.version"
if not exist "%SERVER_JAR%" (
echo [INFO] HytaleServer.jar not found, downloading...
echo [INFO] Expected size: ~150 MB
curl -fL --progress-bar -o "%SERVER_JAR%.tmp" "%JAR_URL%" --connect-timeout 15 --max-time 3600
if exist "%SERVER_JAR%.tmp" (
move /y "%SERVER_JAR%.tmp" "%SERVER_JAR%" >nul
echo [INFO] HytaleServer.jar downloaded
for /f "delims=" %%h in ('powershell -Command "try { (Invoke-WebRequest -Uri '%JAR_URL%' -Method Head -UseBasicParsing).Headers['ETag'] } catch {}" 2^>nul') do (
echo %%h>"%JAR_VERSION_FILE%"
)
) else (
echo [ERROR] Failed to download HytaleServer.jar
echo [ERROR] Check your internet connection
pause
exit /b 1
)
) else (
echo [INFO] Checking for HytaleServer.jar updates...
set "LOCAL_JAR_VER="
if exist "%JAR_VERSION_FILE%" set /p LOCAL_JAR_VER=<"%JAR_VERSION_FILE%"
set "REMOTE_JAR_VER="
for /f "delims=" %%h in ('powershell -Command "try { (Invoke-WebRequest -Uri '%JAR_URL%' -Method Head -UseBasicParsing -TimeoutSec 10).Headers['ETag'] } catch { '' }" 2^>nul') do (
set "REMOTE_JAR_VER=%%h"
)
if defined REMOTE_JAR_VER (
if "!LOCAL_JAR_VER!"=="!REMOTE_JAR_VER!" (
echo [INFO] HytaleServer.jar is up to date
) else (
echo [INFO] HytaleServer.jar update available, downloading...
curl -fL --progress-bar -o "%SERVER_JAR%.tmp" "%JAR_URL%" --connect-timeout 15 --max-time 3600
if exist "%SERVER_JAR%.tmp" (
move /y "%SERVER_JAR%.tmp" "%SERVER_JAR%" >nul
echo !REMOTE_JAR_VER!>"%JAR_VERSION_FILE%"
echo [INFO] HytaleServer.jar updated
) else (
echo [WARN] Update failed, using existing HytaleServer.jar
)
)
) else (
echo [INFO] Could not check for updates, using existing HytaleServer.jar
)
)
:: --- Download / Update Assets.zip ---
set "ASSETS_URL=%DOWNLOAD_BASE%/Assets.zip"
set "ASSETS_VERSION_FILE=%VERSION_DIR%\Assets.zip.version"
if not exist "%ASSETS_PATH%" (
echo [INFO] Assets.zip not found, downloading...
echo [INFO] Expected size: ~3.3 GB - this will take a while
curl -fL --progress-bar -o "%ASSETS_PATH%.tmp" "%ASSETS_URL%" --connect-timeout 15 --max-time 7200
if exist "%ASSETS_PATH%.tmp" (
move /y "%ASSETS_PATH%.tmp" "%ASSETS_PATH%" >nul
echo [INFO] Assets.zip downloaded
for /f "delims=" %%h in ('powershell -Command "try { (Invoke-WebRequest -Uri '%ASSETS_URL%' -Method Head -UseBasicParsing).Headers['ETag'] } catch {}" 2^>nul') do (
echo %%h>"%ASSETS_VERSION_FILE%"
)
) else (
echo [ERROR] Failed to download Assets.zip
echo [ERROR] Check your internet connection
pause
exit /b 1
)
) else (
echo [INFO] Checking for Assets.zip updates...
set "LOCAL_ASSETS_VER="
if exist "%ASSETS_VERSION_FILE%" set /p LOCAL_ASSETS_VER=<"%ASSETS_VERSION_FILE%"
set "REMOTE_ASSETS_VER="
for /f "delims=" %%h in ('powershell -Command "try { (Invoke-WebRequest -Uri '%ASSETS_URL%' -Method Head -UseBasicParsing -TimeoutSec 10).Headers['ETag'] } catch { '' }" 2^>nul') do (
set "REMOTE_ASSETS_VER=%%h"
)
if defined REMOTE_ASSETS_VER (
if "!LOCAL_ASSETS_VER!"=="!REMOTE_ASSETS_VER!" (
echo [INFO] Assets.zip is up to date
) else (
echo [INFO] Assets.zip update available, downloading...
echo [INFO] This is a large file ^(~3.3 GB^), please be patient
curl -fL --progress-bar -o "%ASSETS_PATH%.tmp" "%ASSETS_URL%" --connect-timeout 15 --max-time 7200
if exist "%ASSETS_PATH%.tmp" (
move /y "%ASSETS_PATH%.tmp" "%ASSETS_PATH%" >nul
echo !REMOTE_ASSETS_VER!>"%ASSETS_VERSION_FILE%"
echo [INFO] Assets.zip updated
) else (
echo [WARN] Update failed, using existing Assets.zip
)
)
) else (
echo [INFO] Could not check for updates, using existing Assets.zip
)
)
:: --- Download / Update DualAuth Agent ---
set "AGENT_VERSION_FILE=%VERSION_DIR%\dualauth-agent.jar.version"
if not exist "%AGENT_JAR%" (
echo [INFO] Downloading DualAuth Agent...
curl -fL -# -o "%AGENT_JAR%.tmp" "%AGENT_URL%" --connect-timeout 15 --max-time 120
if exist "%AGENT_JAR%.tmp" (
move /y "%AGENT_JAR%.tmp" "%AGENT_JAR%" >nul
echo [INFO] DualAuth Agent downloaded
for /f "delims=" %%v in ('powershell -Command "try { $r = Invoke-RestMethod -Uri '%AGENT_VERSION_API%' -TimeoutSec 10; $r.tag_name } catch { '' }" 2^>nul') do (
echo %%v>"%AGENT_VERSION_FILE%"
)
) else (
echo [ERROR] Failed to download DualAuth Agent
echo [ERROR] Download manually: %AGENT_URL%
pause
exit /b 1
)
) else (
echo [INFO] Checking for DualAuth Agent updates...
set "LOCAL_AGENT_VER="
if exist "%AGENT_VERSION_FILE%" set /p LOCAL_AGENT_VER=<"%AGENT_VERSION_FILE%"
set "REMOTE_AGENT_VER="
for /f "delims=" %%v in ('powershell -Command "try { $r = Invoke-RestMethod -Uri '%AGENT_VERSION_API%' -TimeoutSec 10; $r.tag_name } catch { '' }" 2^>nul') do (
set "REMOTE_AGENT_VER=%%v"
)
if defined REMOTE_AGENT_VER (
if "!LOCAL_AGENT_VER!"=="!REMOTE_AGENT_VER!" (
echo [INFO] DualAuth Agent up to date ^(!LOCAL_AGENT_VER!^)
) else (
echo [INFO] Agent update: !LOCAL_AGENT_VER! -^> !REMOTE_AGENT_VER!
curl -fL -# -o "%AGENT_JAR%.tmp" "%AGENT_URL%" --connect-timeout 15 --max-time 120
if exist "%AGENT_JAR%.tmp" (
move /y "%AGENT_JAR%.tmp" "%AGENT_JAR%" >nul
echo !REMOTE_AGENT_VER!>"%AGENT_VERSION_FILE%"
echo [INFO] DualAuth Agent updated
) else (
echo [WARN] Agent update failed, using existing
)
)
) else (
echo [INFO] Could not check agent updates, using existing
)
)
:: --- Final Checks ---
if not exist "%SERVER_JAR%" (
echo [ERROR] HytaleServer.jar not found
pause
exit /b 1
)
if not exist "%ASSETS_PATH%" (
echo [ERROR] Assets.zip not found
pause
exit /b 1
)
if not exist "%AGENT_JAR%" (
echo [ERROR] dualauth-agent.jar not found
pause
exit /b 1
)
:: --- Generate or Load Server ID ---
set "SERVER_ID_FILE=.server-id"
if exist "%SERVER_ID_FILE%" (
set /p SERVER_ID=<"%SERVER_ID_FILE%"
echo [INFO] Server ID: !SERVER_ID!
) else (
for /f "delims=" %%i in ('powershell -Command "[guid]::NewGuid().ToString()"') do set "SERVER_ID=%%i"
echo !SERVER_ID!>"%SERVER_ID_FILE%"
echo [INFO] Generated server ID: !SERVER_ID!
)
:: --- Fetch Server Tokens ---
echo.
echo [INFO] Fetching server tokens from %AUTH_SERVER%...
set "TEMP_RESPONSE=%TEMP%\hytale_auth_%RANDOM%.json"
curl -s -X POST "%AUTH_SERVER%/server/auto-auth" ^
-H "Content-Type: application/json" ^
-d "{\"server_id\": \"!SERVER_ID!\", \"server_name\": \"%SERVER_NAME%\"}" ^
--connect-timeout 10 ^
--max-time 30 ^
-o "%TEMP_RESPONSE%" 2>nul
if errorlevel 1 (
echo [ERROR] Failed to connect to auth server at %AUTH_SERVER%
del "%TEMP_RESPONSE%" 2>nul
pause
exit /b 1
)
findstr /C:"sessionToken" "%TEMP_RESPONSE%" >nul 2>&1
if errorlevel 1 (
echo [ERROR] Invalid response from auth server:
type "%TEMP_RESPONSE%"
del "%TEMP_RESPONSE%" 2>nul
pause
exit /b 1
)
:: Extract tokens using PowerShell
for /f "delims=" %%i in ('powershell -Command "$j = Get-Content '%TEMP_RESPONSE%' | ConvertFrom-Json; $j.sessionToken"') do set "SESSION_TOKEN=%%i"
for /f "delims=" %%i in ('powershell -Command "$j = Get-Content '%TEMP_RESPONSE%' | ConvertFrom-Json; $j.identityToken"') do set "IDENTITY_TOKEN=%%i"
del "%TEMP_RESPONSE%" 2>nul
if "!SESSION_TOKEN!"=="" (
echo [ERROR] Could not extract session token from response
pause
exit /b 1
)
if "!IDENTITY_TOKEN!"=="" (
echo [ERROR] Could not extract identity token from response
pause
exit /b 1
)
echo [INFO] Tokens received successfully
:: --- Start Server ---
set "JAVA_ARGS="
if defined JVM_XMS set "JAVA_ARGS=!JAVA_ARGS! -Xms%JVM_XMS%"
if defined JVM_XMX set "JAVA_ARGS=!JAVA_ARGS! -Xmx%JVM_XMX%"
echo.
echo ============================================================
echo Starting Hytale Server
echo Name: %SERVER_NAME%
echo Bind: %BIND_ADDRESS%
echo Java: !JAVA_CMD!
echo Agent: %AGENT_JAR%
echo ============================================================
echo.
"!JAVA_CMD!" %JAVA_ARGS% -javaagent:"%AGENT_JAR%" -jar "%SERVER_JAR%" ^
--assets "%ASSETS_PATH%" ^
--bind "%BIND_ADDRESS%" ^
--auth-mode "%AUTH_MODE%" ^
--disable-sentry ^
--session-token "!SESSION_TOKEN!" ^
--identity-token "!IDENTITY_TOKEN!" ^
%*
echo.
echo ============================================================
echo Server stopped. Exit code: %ERRORLEVEL%
echo ============================================================
pause
endlocal

516
server/start.sh Executable file
View File

@@ -0,0 +1,516 @@
#!/bin/bash
# ============================================================
# Hytale F2P Dedicated Server - One-Click Starter
# ============================================================
# Just run: ./start.sh
#
# The script will:
# 1. Look for game files in your F2P launcher install
# 2. Auto-download anything missing
# 3. Auto-update server, assets, and agent on each launch
# 4. Fetch auth tokens and start the server
# ============================================================
set -e
# Configuration (edit these or set as environment variables)
HYTALE_AUTH_DOMAIN="${HYTALE_AUTH_DOMAIN:-auth.sanasol.ws}"
AUTH_SERVER="${AUTH_SERVER:-https://$HYTALE_AUTH_DOMAIN}"
SERVER_NAME="${SERVER_NAME:-My Hytale Server}"
BIND_ADDRESS="${BIND_ADDRESS:-0.0.0.0:5520}"
AUTH_MODE="${AUTH_MODE:-authenticated}"
# Download URLs
DOWNLOAD_BASE="${DOWNLOAD_BASE:-https://download.sanasol.ws/download}"
AGENT_URL="https://github.com/sanasol/hytale-auth-server/releases/latest/download/dualauth-agent.jar"
AGENT_VERSION_API="https://api.github.com/repos/sanasol/hytale-auth-server/releases/latest"
# File names (in current directory)
AGENT_JAR="dualauth-agent.jar"
SERVER_JAR="HytaleServer.jar"
ASSETS_FILE="Assets.zip"
ASSETS_PATH="${ASSETS_PATH:-./Assets.zip}"
VERSION_DIR=".versions"
echo "============================================================"
echo " Hytale F2P Dedicated Server"
echo "============================================================"
echo ""
# --- Prerequisite Checks ---
if ! command -v curl &>/dev/null; then
echo "[ERROR] curl is required but not found"
echo " Install: sudo apt install curl"
exit 1
fi
mkdir -p "$VERSION_DIR"
# --- Find Local F2P Launcher Install ---
get_f2p_default_dir() {
case "$(uname -s)" in
Darwin) echo "$HOME/Library/Application Support/HytaleF2P" ;;
Linux) echo "$HOME/.hytalef2p" ;;
*) return 1 ;;
esac
}
# Read a JSON string field from config.json using available tools
read_config_field() {
local config_file="$1" field="$2"
if [ ! -f "$config_file" ]; then return 1; fi
if command -v python3 &>/dev/null; then
python3 -c "
import json
try:
c = json.load(open('$config_file'))
v = c.get('$field', '').strip()
if v: print(v)
except: pass
" 2>/dev/null
elif command -v jq &>/dev/null; then
jq -r ".$field // empty" "$config_file" 2>/dev/null
else
grep -o "\"$field\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$config_file" 2>/dev/null | cut -d'"' -f4
fi
}
find_f2p_install() {
local default_app_dir
default_app_dir=$(get_f2p_default_dir) || return 1
local search_dirs=()
# Check config.json for custom installPath
local config_file="$default_app_dir/config.json"
local custom_path
custom_path=$(read_config_field "$config_file" "installPath")
if [ -n "$custom_path" ]; then
local custom_f2p="$custom_path/HytaleF2P"
if [ -d "$custom_f2p" ]; then
search_dirs+=("$custom_f2p")
fi
fi
# Always also check default location
search_dirs+=("$default_app_dir")
for base in "${search_dirs[@]}"; do
for branch in "release" "pre-release"; do
local game_dir="$base/$branch/package/game/latest"
if [ -d "$game_dir" ]; then
echo "$game_dir"
return 0
fi
done
done
return 1
}
# Find bundled JRE from F2P launcher install
find_f2p_java() {
local default_app_dir
default_app_dir=$(get_f2p_default_dir) || return 1
local search_dirs=()
# Check config.json for custom javaPath first
local config_file="$default_app_dir/config.json"
local custom_java
custom_java=$(read_config_field "$config_file" "javaPath")
if [ -n "$custom_java" ] && [ -x "$custom_java" ]; then
echo "$custom_java"
return 0
fi
# Check custom installPath
local custom_path
custom_path=$(read_config_field "$config_file" "installPath")
if [ -n "$custom_path" ]; then
local custom_f2p="$custom_path/HytaleF2P"
[ -d "$custom_f2p" ] && search_dirs+=("$custom_f2p")
fi
search_dirs+=("$default_app_dir")
for base in "${search_dirs[@]}"; do
for branch in "release" "pre-release"; do
local jre_dir="$base/$branch/package/jre/latest"
# Standard path
if [ -x "$jre_dir/bin/java" ]; then
echo "$jre_dir/bin/java"
return 0
fi
# macOS bundle path
if [ -x "$jre_dir/Contents/Home/bin/java" ]; then
echo "$jre_dir/Contents/Home/bin/java"
return 0
fi
done
done
return 1
}
copy_from_f2p() {
local file="$1" local_path="$2" f2p_path="$3"
if [ -f "$local_path" ]; then
return 1 # Already exists locally
fi
if [ -f "$f2p_path" ]; then
echo "[INFO] Found $file in F2P launcher install"
echo "[INFO] Copying from: $f2p_path"
cp "$f2p_path" "$local_path"
echo "[INFO] Copied successfully"
return 0
fi
return 1
}
# --- Detect Java ---
JAVA_CMD="java"
# Try F2P bundled JRE first
F2P_JAVA=$(find_f2p_java 2>/dev/null) || true
if [ -n "$F2P_JAVA" ]; then
echo "[INFO] Found Java in F2P launcher: $F2P_JAVA"
JAVA_CMD="$F2P_JAVA"
elif ! command -v java &>/dev/null; then
echo "[ERROR] Java is not installed and no F2P launcher JRE found"
echo ""
echo " Options:"
echo " 1. Install the F2P launcher first (it includes Java)"
echo " 2. Install Java 25+ manually:"
echo " Windows: https://download.oracle.com/java/25/latest/jdk-25_windows-x64_bin.exe"
echo " macOS: brew install openjdk"
echo " Ubuntu/Debian: sudo apt install openjdk-25-jre"
echo ""
exit 1
fi
# Check Java version
JAVA_FULL=$("$JAVA_CMD" -version 2>&1 | head -1)
JAVA_VER=$(echo "$JAVA_FULL" | grep -oP '(?<=")\d+' 2>/dev/null || echo "$JAVA_FULL" | sed 's/.*"\([0-9]*\).*/\1/')
echo "[INFO] Java: $JAVA_FULL"
if [ -n "$JAVA_VER" ] && [ "$JAVA_VER" -lt 25 ] 2>/dev/null; then
echo "[ERROR] Java $JAVA_VER detected. Java 25+ is REQUIRED."
echo " The DualAuth agent requires Java 25 (class file version 69)."
echo ""
if [ -n "$F2P_JAVA" ]; then
echo " Your F2P launcher JRE is outdated. Update the launcher to get a newer Java."
else
echo " Install Java 25+:"
echo " Windows: https://download.oracle.com/java/25/latest/jdk-25_windows-x64_bin.exe"
echo " macOS: brew install openjdk"
echo " Ubuntu/Debian: sudo apt install openjdk-25-jre"
fi
echo ""
exit 1
fi
# --- Find F2P Game Files ---
F2P_DIR=""
if F2P_DIR=$(find_f2p_install 2>/dev/null); then
echo "[INFO] Found F2P launcher game files: $F2P_DIR"
# Try to copy HytaleServer.jar from F2P install
copy_from_f2p "HytaleServer.jar" "$SERVER_JAR" "$F2P_DIR/Server/HytaleServer.jar" || true
# Try to copy Assets.zip from F2P install
copy_from_f2p "Assets.zip" "$ASSETS_PATH" "$F2P_DIR/Assets.zip" || true
echo ""
else
echo "[INFO] No F2P launcher install found, will download files"
echo ""
fi
# --- Download / Update Functions ---
get_remote_version() {
local url="$1"
local headers
headers=$(curl -sI -L "$url" --connect-timeout 10 --max-time 15 2>/dev/null | tr -d '\r')
local etag
etag=$(echo "$headers" | grep -i "^etag:" | tail -1 | sed 's/^[^:]*: *//' | tr -d '"')
if [ -n "$etag" ]; then printf '%s' "$etag"; return 0; fi
local lastmod
lastmod=$(echo "$headers" | grep -i "^last-modified:" | tail -1 | sed 's/^[^:]*: *//')
if [ -n "$lastmod" ]; then printf '%s' "$lastmod"; return 0; fi
local length
length=$(echo "$headers" | grep -i "^content-length:" | tail -1 | sed 's/^[^:]*: *//')
if [ -n "$length" ]; then printf 'size:%s' "$length"; return 0; fi
return 1
}
needs_update() {
local url="$1" dest="$2" name="$3"
local version_file="${VERSION_DIR}/${name}.version"
if [ ! -f "$dest" ]; then
echo "[INFO] $name not found, will download"
return 0
fi
echo "[INFO] Checking for $name updates..."
local remote_version
remote_version=$(get_remote_version "$url" 2>/dev/null) || true
if [ -z "$remote_version" ]; then
echo "[INFO] Could not check for updates, using existing $name"
return 1
fi
local local_version=""
[ -f "$version_file" ] && local_version=$(cat "$version_file" 2>/dev/null)
if [ "$remote_version" = "$local_version" ]; then
echo "[INFO] $name is up to date"
return 1
fi
if [ -n "$local_version" ]; then
echo "[INFO] $name update available"
fi
return 0
}
save_version() {
local url="$1" name="$2"
local version_file="${VERSION_DIR}/${name}.version"
local ver
ver=$(get_remote_version "$url" 2>/dev/null) || true
[ -n "$ver" ] && printf '%s\n' "$ver" > "$version_file"
}
download_file() {
local url="$1" dest="$2" name="$3" expected_mb="${4:-0}"
local tmp="${dest}.tmp"
echo "[INFO] Downloading $name..."
[ "$expected_mb" -gt 0 ] 2>/dev/null && echo "[INFO] Expected size: ~${expected_mb} MB"
for attempt in 1 2 3; do
rm -f "$tmp" 2>/dev/null || true
if [ "$expected_mb" -gt 50 ] 2>/dev/null; then
curl -fL --progress-bar -o "$tmp" "$url" --connect-timeout 15 --max-time 3600 2>&1
else
curl -fL -# -o "$tmp" "$url" --connect-timeout 15 --max-time 300 2>&1
fi
if [ $? -eq 0 ] && [ -f "$tmp" ]; then
local size
size=$(stat -c%s "$tmp" 2>/dev/null || stat -f%z "$tmp" 2>/dev/null || echo 0)
if [ "$size" -gt 1000 ]; then
mv -f "$tmp" "$dest"
local mb=$((size / 1024 / 1024))
echo "[INFO] $name downloaded (${mb} MB)"
return 0
fi
echo "[WARN] $name download too small (${size} bytes), retrying..."
fi
echo "[WARN] Download attempt $attempt failed, retrying..."
rm -f "$tmp" 2>/dev/null || true
sleep 2
done
echo "[ERROR] Failed to download $name after 3 attempts"
rm -f "$tmp" 2>/dev/null || true
return 1
}
# --- Download / Update Server Files ---
# HytaleServer.jar
JAR_URL="${DOWNLOAD_BASE}/HytaleServer.jar"
if needs_update "$JAR_URL" "$SERVER_JAR" "HytaleServer.jar"; then
if download_file "$JAR_URL" "$SERVER_JAR" "HytaleServer.jar" "150"; then
save_version "$JAR_URL" "HytaleServer.jar"
else
if [ ! -f "$SERVER_JAR" ]; then
echo "[ERROR] HytaleServer.jar is required. Check your internet connection."
exit 1
fi
echo "[WARN] Update failed, using existing HytaleServer.jar"
fi
fi
# Assets.zip
ASSETS_URL="${DOWNLOAD_BASE}/Assets.zip"
if needs_update "$ASSETS_URL" "$ASSETS_PATH" "Assets.zip"; then
echo "[INFO] Assets.zip is large (~3.3 GB), this may take a while..."
if download_file "$ASSETS_URL" "$ASSETS_PATH" "Assets.zip" "3300"; then
save_version "$ASSETS_URL" "Assets.zip"
else
if [ ! -f "$ASSETS_PATH" ]; then
echo "[ERROR] Assets.zip is required. Check your internet connection."
exit 1
fi
echo "[WARN] Update failed, using existing Assets.zip"
fi
fi
# DualAuth Agent (uses GitHub releases API for version tracking)
check_agent_update() {
if [ -f "$AGENT_JAR" ]; then
local agent_size
agent_size=$(stat -c%s "$AGENT_JAR" 2>/dev/null || stat -f%z "$AGENT_JAR" 2>/dev/null || echo 0)
if [ "$agent_size" -lt 10000 ]; then
echo "[WARN] Agent JAR seems corrupt (${agent_size} bytes), re-downloading..."
rm -f "$AGENT_JAR"
return 0
fi
local version_file="${VERSION_DIR}/${AGENT_JAR}.version"
local local_version=""
[ -f "$version_file" ] && local_version=$(cat "$version_file" 2>/dev/null)
echo "[INFO] Checking for DualAuth Agent updates..."
local remote_version
remote_version=$(curl -sf "$AGENT_VERSION_API" --connect-timeout 5 --max-time 10 2>/dev/null | grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
if [ -n "$remote_version" ]; then
if [ "$local_version" = "$remote_version" ]; then
echo "[INFO] DualAuth Agent up to date ($local_version)"
return 1
else
echo "[INFO] Agent update: ${local_version:-unknown} -> $remote_version"
return 0
fi
else
echo "[INFO] Could not check agent updates, using existing"
return 1
fi
else
return 0
fi
}
AGENT_REMOTE_VERSION=""
if check_agent_update; then
echo "[INFO] Downloading DualAuth Agent..."
if curl -fL -# -o "${AGENT_JAR}.tmp" "$AGENT_URL" --connect-timeout 15 --max-time 120 2>&1 && [ -f "${AGENT_JAR}.tmp" ]; then
dl_size=$(stat -c%s "${AGENT_JAR}.tmp" 2>/dev/null || stat -f%z "${AGENT_JAR}.tmp" 2>/dev/null || echo 0)
if [ "$dl_size" -gt 10000 ]; then
mv -f "${AGENT_JAR}.tmp" "$AGENT_JAR"
AGENT_REMOTE_VERSION=$(curl -sf "$AGENT_VERSION_API" --connect-timeout 5 --max-time 10 2>/dev/null | grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
[ -n "$AGENT_REMOTE_VERSION" ] && printf '%s\n' "$AGENT_REMOTE_VERSION" > "${VERSION_DIR}/${AGENT_JAR}.version"
echo "[INFO] DualAuth Agent ready (${AGENT_REMOTE_VERSION:-latest})"
else
echo "[WARN] Downloaded agent too small, discarding"
rm -f "${AGENT_JAR}.tmp"
fi
else
rm -f "${AGENT_JAR}.tmp" 2>/dev/null
if [ -f "$AGENT_JAR" ]; then
echo "[WARN] Agent update failed, using existing"
else
echo "[ERROR] Failed to download DualAuth Agent"
echo "[ERROR] Download manually: $AGENT_URL"
exit 1
fi
fi
fi
# --- Final Checks ---
for required in "$SERVER_JAR" "$ASSETS_PATH" "$AGENT_JAR"; do
if [ ! -f "$required" ]; then
echo "[ERROR] Required file missing: $required"
exit 1
fi
done
# --- Generate Server ID ---
SERVER_ID_FILE=".server-id"
if [ -f "$SERVER_ID_FILE" ]; then
SERVER_ID=$(cat "$SERVER_ID_FILE")
echo "[INFO] Server ID: $SERVER_ID"
else
SERVER_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen 2>/dev/null || python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null)
if [ -z "$SERVER_ID" ]; then
echo "[ERROR] Could not generate UUID. Install uuidgen or python3."
exit 1
fi
printf '%s' "$SERVER_ID" > "$SERVER_ID_FILE"
echo "[INFO] Generated server ID: $SERVER_ID"
fi
# --- Fetch Tokens ---
echo ""
echo "[INFO] Fetching server tokens from $AUTH_SERVER..."
TEMP_RESPONSE=$(mktemp)
curl -s -X POST "$AUTH_SERVER/server/auto-auth" \
-H "Content-Type: application/json" \
-d "{\"server_id\": \"$SERVER_ID\", \"server_name\": \"$SERVER_NAME\"}" \
--connect-timeout 10 \
--max-time 30 \
-o "$TEMP_RESPONSE"
if [ $? -ne 0 ]; then
echo "[ERROR] Failed to connect to auth server at $AUTH_SERVER"
rm -f "$TEMP_RESPONSE"
exit 1
fi
if ! grep -q "sessionToken" "$TEMP_RESPONSE" 2>/dev/null; then
echo "[ERROR] Invalid response from auth server:"
cat "$TEMP_RESPONSE"
rm -f "$TEMP_RESPONSE"
exit 1
fi
# Extract tokens (python3 > jq > grep fallback)
if command -v python3 &>/dev/null; then
SESSION_TOKEN=$(python3 -c "import json,sys; print(json.load(open('$TEMP_RESPONSE'))['sessionToken'])")
IDENTITY_TOKEN=$(python3 -c "import json,sys; print(json.load(open('$TEMP_RESPONSE'))['identityToken'])")
elif command -v jq &>/dev/null; then
SESSION_TOKEN=$(jq -r '.sessionToken' "$TEMP_RESPONSE")
IDENTITY_TOKEN=$(jq -r '.identityToken' "$TEMP_RESPONSE")
else
SESSION_TOKEN=$(grep -o '"sessionToken":"[^"]*"' "$TEMP_RESPONSE" | cut -d'"' -f4)
IDENTITY_TOKEN=$(grep -o '"identityToken":"[^"]*"' "$TEMP_RESPONSE" | cut -d'"' -f4)
fi
rm -f "$TEMP_RESPONSE"
if [ -z "$SESSION_TOKEN" ] || [ -z "$IDENTITY_TOKEN" ]; then
echo "[ERROR] Could not extract tokens from auth server response"
exit 1
fi
echo "[INFO] Tokens received successfully"
# --- Start Server ---
JAVA_ARGS=""
[ -n "${JVM_XMS:-}" ] && JAVA_ARGS="$JAVA_ARGS -Xms$JVM_XMS"
[ -n "${JVM_XMX:-}" ] && JAVA_ARGS="$JAVA_ARGS -Xmx$JVM_XMX"
echo ""
echo "============================================================"
echo " Starting Hytale Server"
echo " Name: $SERVER_NAME"
echo " Bind: $BIND_ADDRESS"
echo " Agent: $AGENT_JAR"
echo "============================================================"
echo ""
exec "$JAVA_CMD" $JAVA_ARGS -javaagent:"$AGENT_JAR" -jar "$SERVER_JAR" \
--assets "$ASSETS_PATH" \
--bind "$BIND_ADDRESS" \
--auth-mode "$AUTH_MODE" \
--disable-sentry \
--session-token "$SESSION_TOKEN" \
--identity-token "$IDENTITY_TOKEN" \
"$@"