diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c1c8f4d --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +CURSEFORGE_API_KEY=$1234asdxXXXXXXkQCXXXXXXXXXXASDb32 +DISCORD_CLIENT_ID=561263XXXXXX \ No newline at end of file diff --git a/.gitignore b/.gitignore index 56f6219..b533c73 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,7 @@ pkg/ # Package files *.tar.zst -*.zst \ No newline at end of file +*.zst.DS_Store +*.zst +bun.lockb +.env diff --git a/GUI/index.html b/GUI/index.html index eb7b8e7..c1d0399 100644 --- a/GUI/index.html +++ b/GUI/index.html @@ -51,11 +51,7 @@ Settings - -
@@ -114,7 +110,7 @@

- HYTALE + HYTALE

FREE TO PLAY LAUNCHER

@@ -122,22 +118,26 @@
- +
- +
- + @@ -163,7 +163,8 @@ READY TO PLAY -

Launch Hytale and enter the adventure

+

Launch Hytale and enter the + adventure

@@ -191,7 +193,8 @@
- +
- Page 1 of 1 + Page 1 of 1
@@ -320,8 +329,10 @@ onclick="repairGame()">
-
Repair Game
-
Reinstall game files (preserves data) +
Repair Game +
+
+ Reinstall game files (preserves data)
@@ -329,18 +340,25 @@
- +
- + - - - - + + + +

- Select your preferred GPU (Linux: affects DRI_PRIME) + Select your preferred GPU (Linux: + affects DRI_PRIME)

@@ -355,7 +373,8 @@
- +
@@ -369,7 +388,8 @@

- Your unique player identifier for this username + Your unique player identifier for + this username

@@ -379,8 +399,10 @@
@@ -398,14 +420,38 @@
-
Enable Discord Rich Presence
-
Show your launcher activity on Discord +
Enable + Discord Rich Presence
+
Show your launcher activity + on Discord
+
+

+ + Launcher Behavior +

+ +
+ +
+
+ +

@@ -417,8 +463,10 @@
-
Use Custom Java Path
-
Override the bundled Java runtime with +
Use + Custom Java Path
+
+ Override the bundled Java runtime with your own installation
@@ -426,7 +474,8 @@
- +

@@ -451,7 +501,8 @@
- + @@ -462,14 +513,6 @@
-
-
- -

Skins

-

Skin customization coming soon...

-
-
-
@@ -482,15 +525,18 @@ Copy
-
Loading logs...
+
Loading + logs...

@@ -532,6 +578,20 @@

+ + + @@ -646,8 +708,8 @@
- + @@ -656,6 +718,11 @@
+
+ + Loading... +
+
diff --git a/GUI/js/install.js b/GUI/js/install.js index 36bb46f..86a0ede 100644 --- a/GUI/js/install.js +++ b/GUI/js/install.js @@ -39,6 +39,19 @@ export function setupInstallation() { } }); } + + // Setup installation effects listeners + if (window.electronAPI && window.electronAPI.onInstallationStart) { + window.electronAPI.onInstallationStart(() => { + showInstallationEffects(); + }); + } + + if (window.electronAPI && window.electronAPI.onInstallationEnd) { + window.electronAPI.onInstallationEnd(() => { + hideInstallationEffects(); + }); + } } export async function installGame() { @@ -78,12 +91,19 @@ export async function installGame() { } } catch (error) { const errorMsg = window.i18n ? window.i18n.t('progress.installationFailed').replace('{error}', error.message) : `Installation failed: ${error.message}`; + + // Hide installation effects on error + if (window.hideInstallationEffects) { + window.hideInstallationEffects(); + } + + // Reset button state on error + resetInstallButton(); + if (window.LauncherUI) { window.LauncherUI.updateProgress({ message: errorMsg }); - setTimeout(() => { - window.LauncherUI.hideProgress(); - resetInstallButton(); - }, 3000); + // Don't hide progress bar, just update the message + // User can see the error and close it manually } } } diff --git a/GUI/js/mods.js b/GUI/js/mods.js index 32f4ddd..631db3f 100644 --- a/GUI/js/mods.js +++ b/GUI/js/mods.js @@ -1,5 +1,5 @@ -const API_KEY = '$2a$10$bqk254NMZOWVTzLVJCcxEOmhcyUujKxA5xk.kQCN9q0KNYFJd5b32'; +let API_KEY = null; const CURSEFORGE_API = 'https://api.curseforge.com/v1'; const HYTALE_GAME_ID = 70216; @@ -11,6 +11,15 @@ let modsPageSize = 20; let modsTotalPages = 1; export async function initModsManager() { + try { + if (window.electronAPI && window.electronAPI.getEnvVar) { + API_KEY = await window.electronAPI.getEnvVar('CURSEFORGE_API_KEY'); + console.log('Loaded API Key:', API_KEY ? 'Yes' : 'No'); + } + } catch (err) { + console.error('Failed to load API Key:', err); + } + setupModsEventListeners(); await loadInstalledMods(); await loadBrowseMods(); @@ -417,10 +426,10 @@ async function deleteMod(modId) { const mod = installedMods.find(m => m.id === modId); if (!mod) return; - const confirmMsg = window.i18n ? + const confirmMsg = window.i18n ? window.i18n.t('mods.confirmDelete').replace('{name}', mod.name) + ' ' + window.i18n.t('mods.confirmDeleteDesc') : `Are you sure you want to delete "${mod.name}"? This action cannot be undone.`; - + showConfirmModal( confirmMsg, async () => { diff --git a/GUI/js/settings.js b/GUI/js/settings.js index 0ec95e6..dd383be 100644 --- a/GUI/js/settings.js +++ b/GUI/js/settings.js @@ -3,9 +3,11 @@ let customJavaCheck; let customJavaOptions; let customJavaPath; let browseJavaBtn; -let settingsPlayerName; -let discordRPCCheck; -let gpuPreferenceRadios; +let settingsPlayerName; +let discordRPCCheck; +let closeLauncherCheck; +let gpuPreferenceRadios; + // UUID Management elements let currentUuidDisplay; @@ -159,9 +161,11 @@ function setupSettingsElements() { customJavaOptions = document.getElementById('customJavaOptions'); customJavaPath = document.getElementById('customJavaPath'); browseJavaBtn = document.getElementById('browseJavaBtn'); - settingsPlayerName = document.getElementById('settingsPlayerName'); - discordRPCCheck = document.getElementById('discordRPCCheck'); - gpuPreferenceRadios = document.querySelectorAll('input[name="gpuPreference"]'); + settingsPlayerName = document.getElementById('settingsPlayerName'); + discordRPCCheck = document.getElementById('discordRPCCheck'); + closeLauncherCheck = document.getElementById('closeLauncherCheck'); + gpuPreferenceRadios = document.querySelectorAll('input[name="gpuPreference"]'); + // UUID Management elements currentUuidDisplay = document.getElementById('currentUuid'); @@ -190,9 +194,14 @@ function setupSettingsElements() { settingsPlayerName.addEventListener('change', savePlayerName); } - if (discordRPCCheck) { - discordRPCCheck.addEventListener('change', saveDiscordRPC); - } + if (discordRPCCheck) { + discordRPCCheck.addEventListener('change', saveDiscordRPC); + } + + if (closeLauncherCheck) { + closeLauncherCheck.addEventListener('change', saveCloseLauncher); + } + // UUID event listeners if (copyUuidBtn) { @@ -335,18 +344,43 @@ async function saveDiscordRPC() { } } -async function loadDiscordRPC() { - try { - if (window.electronAPI && window.electronAPI.loadDiscordRPC) { - const enabled = await window.electronAPI.loadDiscordRPC(); - if (discordRPCCheck) { - discordRPCCheck.checked = enabled; - } - } - } catch (error) { - console.error('Error loading Discord RPC setting:', error); - } -} +async function loadDiscordRPC() { + try { + if (window.electronAPI && window.electronAPI.loadDiscordRPC) { + const enabled = await window.electronAPI.loadDiscordRPC(); + if (discordRPCCheck) { + discordRPCCheck.checked = enabled; + } + } + } catch (error) { + console.error('Error loading Discord RPC setting:', error); + } +} + +async function saveCloseLauncher() { + try { + if (window.electronAPI && window.electronAPI.saveCloseLauncher && closeLauncherCheck) { + const enabled = closeLauncherCheck.checked; + await window.electronAPI.saveCloseLauncher(enabled); + } + } catch (error) { + console.error('Error saving close launcher setting:', error); + } +} + +async function loadCloseLauncher() { + try { + if (window.electronAPI && window.electronAPI.loadCloseLauncher) { + const enabled = await window.electronAPI.loadCloseLauncher(); + if (closeLauncherCheck) { + closeLauncherCheck.checked = enabled; + } + } + } catch (error) { + console.error('Error loading close launcher setting:', error); + } +} + async function savePlayerName() { try { @@ -457,13 +491,15 @@ async function loadGpuPreference() { } } -async function loadAllSettings() { - await loadCustomJavaPath(); - await loadPlayerName(); - await loadCurrentUuid(); - await loadDiscordRPC(); - await loadGpuPreference(); -} +async function loadAllSettings() { + await loadCustomJavaPath(); + await loadPlayerName(); + await loadCurrentUuid(); + await loadDiscordRPC(); + await loadCloseLauncher(); + await loadGpuPreference(); +} + async function openGameLocation() { try { diff --git a/GUI/js/ui.js b/GUI/js/ui.js index f417c87..6768f95 100644 --- a/GUI/js/ui.js +++ b/GUI/js/ui.js @@ -479,6 +479,9 @@ function setupUI() { progressSpeed = document.getElementById('progressSpeed'); progressSize = document.getElementById('progressSize'); + // Setup draggable progress bar + setupProgressDrag(); + lockPlayButton(true); setTimeout(() => { @@ -497,10 +500,26 @@ function setupUI() { setupSidebarLogo(); setupAnimations(); setupFirstLaunchHandlers(); + loadLauncherVersion(); document.body.focus(); } +// Load launcher version from package.json +async function loadLauncherVersion() { + try { + if (window.electronAPI && window.electronAPI.getVersion) { + const version = await window.electronAPI.getVersion(); + const versionElement = document.getElementById('launcherVersion'); + if (versionElement) { + versionElement.textContent = `v${version}`; + } + } + } catch (error) { + console.error('Failed to load launcher version:', error); + } +} + window.LauncherUI = { showPage, setActiveNav, @@ -510,4 +529,91 @@ window.LauncherUI = { updateProgress }; +// Make installation effects globally available +window.showInstallationEffects = showInstallationEffects; +window.hideInstallationEffects = hideInstallationEffects; + +// Draggable progress bar functionality +function setupProgressDrag() { + if (!progressOverlay) return; + + let isDragging = false; + let offsetX; + let offsetY; + + progressOverlay.addEventListener('mousedown', dragStart); + document.addEventListener('mousemove', drag); + document.addEventListener('mouseup', dragEnd); + + function dragStart(e) { + // Only drag if clicking on the overlay itself, not on buttons or inputs + if (e.target.closest('.progress-bar-fill')) return; + + if (e.target === progressOverlay || e.target.closest('.progress-content')) { + isDragging = true; + progressOverlay.classList.add('dragging'); + + // Get the current position of the progress overlay + const rect = progressOverlay.getBoundingClientRect(); + offsetX = e.clientX - rect.left - progressOverlay.offsetWidth / 2; + offsetY = e.clientY - rect.top; + } + } + + function drag(e) { + if (isDragging) { + e.preventDefault(); + + // Calculate new position + const newX = e.clientX - offsetX - progressOverlay.offsetWidth / 2; + const newY = e.clientY - offsetY; + + // Get window bounds + const maxX = window.innerWidth - progressOverlay.offsetWidth; + const maxY = window.innerHeight - progressOverlay.offsetHeight; + const minX = 0; + const minY = 0; + + // Constrain to window bounds + const constrainedX = Math.max(minX, Math.min(newX, maxX)); + const constrainedY = Math.max(minY, Math.min(newY, maxY)); + + progressOverlay.style.left = constrainedX + 'px'; + progressOverlay.style.bottom = 'auto'; + progressOverlay.style.top = constrainedY + 'px'; + progressOverlay.style.transform = 'none'; + } + } + + function dragEnd() { + isDragging = false; + progressOverlay.classList.remove('dragging'); + } +} + +// Show/hide installation effects +function showInstallationEffects() { + const installationEffects = document.getElementById('installationEffects'); + if (installationEffects) { + installationEffects.style.display = 'block'; + } +} + +function hideInstallationEffects() { + const installationEffects = document.getElementById('installationEffects'); + if (installationEffects) { + installationEffects.style.display = 'none'; + } +} + +// Toggle maximize/restore window function +function toggleMaximize() { + if (window.electronAPI && window.electronAPI.maximizeWindow) { + window.electronAPI.maximizeWindow(); + } +} + +// Make toggleMaximize globally available +window.toggleMaximize = toggleMaximize; + document.addEventListener('DOMContentLoaded', setupUI); diff --git a/GUI/locales/en.json b/GUI/locales/en.json index 8e48030..d142831 100644 --- a/GUI/locales/en.json +++ b/GUI/locales/en.json @@ -4,14 +4,12 @@ "mods": "Mods", "news": "News", "chat": "Players Chat", - "settings": "Settings", - "skins": "Skins" + "settings": "Settings" }, "header": { "playersLabel": "Players:", "manageProfiles": "Manage Profiles", - "defaultProfile": "Default", - "f2p": "FREE TO PLAY" + "defaultProfile": "Default" }, "install": { "title": "FREE TO PLAY LAUNCHER", @@ -124,7 +122,10 @@ "logsCopy": "Copy", "logsRefresh": "Refresh", "logsFolder": "Open Folder", - "logsLoading": "Loading logs..." + "logsLoading": "Loading logs...", + "closeLauncher": "Launcher Behavior", + "closeOnStart": "Close Launcher on game start", + "closeOnStartDescription": "Automatically close the launcher after Hytale has launched" }, "uuid": { "modalTitle": "UUID Management", @@ -148,10 +149,6 @@ "notificationText": "Join our Discord community!", "joinButton": "Join Discord" }, - "skins": { - "title": "Skins", - "comingSoon": "Skin customization coming soon..." - }, "common": { "confirm": "Confirm", "cancel": "Cancel", diff --git a/GUI/locales/es.json b/GUI/locales/es.json index fc3f4c4..4bb89c8 100644 --- a/GUI/locales/es.json +++ b/GUI/locales/es.json @@ -4,14 +4,12 @@ "mods": "Mods", "news": "Noticias", "chat": "Chat de Jugadores", - "settings": "Configuración", - "skins": "Aspectos" + "settings": "Configuración" }, "header": { "playersLabel": "Jugadores:", "manageProfiles": "Gestionar Perfiles", - "defaultProfile": "Predeterminado", - "f2p": "FREE TO PLAY" + "defaultProfile": "Predeterminado" }, "install": { "title": "LAUNCHER GRATUITO", @@ -124,7 +122,10 @@ "logsCopy": "Copiar", "logsRefresh": "Actualizar", "logsFolder": "Abrir Carpeta", - "logsLoading": "Cargando registros..." + "logsLoading": "Cargando registros...", + "closeLauncher": "Comportamiento del Launcher", + "closeOnStart": "Cerrar Launcher al iniciar el juego", + "closeOnStartDescription": "Cierra automáticamente el launcher después de que Hytale se haya iniciado" }, "uuid": { "modalTitle": "Gestión de UUID", @@ -148,10 +149,6 @@ "notificationText": "¡Únete a nuestra comunidad de Discord!", "joinButton": "Unirse a Discord" }, - "skins": { - "title": "Aspectos", - "comingSoon": "Personalización de aspectos próximamente..." - }, "common": { "confirm": "Confirmar", "cancel": "Cancelar", diff --git a/GUI/locales/pt-BR.json b/GUI/locales/pt-BR.json index 02c1dcf..492440b 100644 --- a/GUI/locales/pt-BR.json +++ b/GUI/locales/pt-BR.json @@ -4,14 +4,12 @@ "mods": "Mods", "news": "Notícias", "chat": "Chat de Jogadores", - "settings": "Configurações", - "skins": "Aparências" + "settings": "Configurações" }, "header": { "playersLabel": "Jogadores:", "manageProfiles": "Gerenciar Perfis", - "defaultProfile": "Padrão", - "f2p": "FREE TO PLAY" + "defaultProfile": "Padrão" }, "install": { "title": "LANÇADOR JOGO GRATUITO", @@ -124,7 +122,10 @@ "logsCopy": "Copiar", "logsRefresh": "Atualizar", "logsFolder": "Abrir Pasta", - "logsLoading": "Carregando registros..." + "logsLoading": "Carregando registros...", + "closeLauncher": "Comportamento do Lançador", + "closeOnStart": "Fechar Lançador ao iniciar o jogo", + "closeOnStartDescription": "Fechar automaticamente o lançador após o Hytale ter sido iniciado" }, "uuid": { "modalTitle": "Gerenciamento de UUID", @@ -148,10 +149,7 @@ "notificationText": "Junte-se à nossa comunidade do Discord!", "joinButton": "Entrar no Discord" }, - "skins": { - "title": "Aparências", - "comingSoon": "Personalização de aparências em breve..." - }, + "common": { "confirm": "Confirmar", "cancel": "Cancelar", diff --git a/GUI/splash.html b/GUI/splash.html new file mode 100644 index 0000000..f4eefb1 --- /dev/null +++ b/GUI/splash.html @@ -0,0 +1,178 @@ + + + + + + Hytale F2P + + + + +
+ Background +
+ +
+ +

+ HYTALE +

+

FREE TO PLAY LAUNCHER

+
+

Loading...

+
+ + diff --git a/GUI/style.css b/GUI/style.css index 3e7787f..10967ff 100644 --- a/GUI/style.css +++ b/GUI/style.css @@ -26,7 +26,7 @@ body { backdrop-filter: blur(20px); border-right: 1px solid rgba(255, 255, 255, 0.1); position: relative; - z-index: 20; + z-index: 45; } .sidebar-logo { @@ -109,6 +109,12 @@ body { transform: scale(1.1); } +/* Allow logs navigation during installation */ +.logs-nav-item { + z-index: 100; + position: relative; +} + .nav-tooltip { position: absolute; left: 100%; @@ -210,6 +216,63 @@ body { border-color: rgba(147, 51, 234, 0.3); } +.version-display { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + color: #9ca3af; + pointer-events: auto; + transition: all 0.3s ease; +} + +.version-display i { + color: #9333ea; + font-size: 0.875rem; +} + +.version-display:hover { + background: rgba(0, 0, 0, 0.6); + border-color: rgba(147, 51, 234, 0.3); + color: #ffffff; +} + +.version-display-bottom { + position: fixed; + bottom: 3rem; + right: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + color: #9ca3af; + z-index: 45; + transition: all 0.3s ease; +} + +.version-display-bottom i { + color: #9333ea; + font-size: 0.875rem; +} + +.version-display-bottom:hover { + background: rgba(0, 0, 0, 0.8); + border-color: rgba(147, 51, 234, 0.3); + color: #ffffff; +} + .user-info { display: flex; @@ -374,10 +437,10 @@ body { } .control-btn { - width: 20px; - height: 20px; - border-radius: 50%; - border: none; + width: 28px; + height: 28px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer !important; transition: all 0.3s ease; display: flex !important; @@ -386,24 +449,36 @@ body { position: relative; z-index: 100000 !important; pointer-events: auto !important; + backdrop-filter: blur(10px); } .control-btn i { - font-size: 0.5rem; - opacity: 0; + font-size: 0.75rem; + opacity: 0.7; transition: opacity 0.3s ease; + color: white; } .control-btn:hover i { opacity: 1; } +.maximize { + background: rgba(34, 197, 94, 0.2); +} + +.maximize:hover { + background: rgba(34, 197, 94, 0.4); + border-color: rgba(34, 197, 94, 0.5); +} + .minimize { background: rgba(251, 191, 36, 0.2); } .minimize:hover { - background: #fbbf24; + background: rgba(251, 191, 36, 0.4); + border-color: rgba(251, 191, 36, 0.5); } .close { @@ -411,7 +486,8 @@ body { } .close:hover { - background: #ef4444; + background: rgba(239, 68, 68, 0.4); + border-color: rgba(239, 68, 68, 0.5); } @@ -429,7 +505,7 @@ body { } .title-accent { - color: #9333ea; + color: #bf84f7; text-shadow: 0 0 20px rgba(147, 51, 234, 0.5); } @@ -928,15 +1004,22 @@ body { .news-grid-horizontal { - display: flex; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + grid-auto-rows: minmax(200px, 1fr); gap: 1rem; - overflow-x: auto; + overflow-y: auto; + overflow-x: hidden; padding-bottom: 1rem; scrollbar-width: thin; scrollbar-color: rgba(147, 51, 234, 0.3) transparent; + flex: 1; + min-height: 0; + align-content: start; } .news-grid-horizontal::-webkit-scrollbar { + width: 6px; height: 6px; } @@ -954,9 +1037,11 @@ body { } .news-grid-horizontal .news-item { - min-width: 300px; - max-width: 300px; - height: 200px; + width: 100%; + min-width: 0; + max-width: none; + height: auto; + aspect-ratio: 16 / 9; flex-shrink: 0; } @@ -997,6 +1082,12 @@ body { border: 1px solid rgba(255, 255, 255, 0.1); } +/* Style spécifique pour LATEST NEWS (Play tab) */ +.news-grid-horizontal .news-card { + aspect-ratio: unset; + height: 100%; +} + .news-card:hover { box-shadow: 0 8px 40px rgba(147, 51, 234, 0.2); border-color: rgba(147, 51, 234, 0.3); @@ -1500,44 +1591,55 @@ body { .progress-overlay { position: fixed; - bottom: 1rem; - left: 1rem; - right: 1rem; - background: rgba(0, 0, 0, 0.85); - backdrop-filter: blur(30px); - border: 2px solid rgba(147, 51, 234, 0.3); - border-radius: 16px; - padding: 2rem; - z-index: 50; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + width: 400px; + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(20px); + border: 1px solid rgba(147, 51, 234, 0.3); + border-radius: 12px; + padding: 1.25rem; + z-index: 60; box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.6), - 0 0 40px rgba(147, 51, 234, 0.1), - inset 0 1px 0 rgba(255, 255, 255, 0.1); + 0 4px 16px rgba(0, 0, 0, 0.5), + 0 0 30px rgba(147, 51, 234, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.05); animation: progressGlow 3s ease-in-out infinite alternate; + cursor: move; + user-select: none; +} + +.progress-overlay.dragging { + cursor: grabbing; + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.7), + 0 0 50px rgba(147, 51, 234, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.05); } @keyframes progressGlow { 0% { box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.6), - 0 0 40px rgba(147, 51, 234, 0.1), - inset 0 1px 0 rgba(255, 255, 255, 0.1); + 0 4px 16px rgba(0, 0, 0, 0.5), + 0 0 30px rgba(147, 51, 234, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.05); border-color: rgba(147, 51, 234, 0.3); } 100% { box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.6), - 0 0 60px rgba(147, 51, 234, 0.3), - inset 0 1px 0 rgba(255, 255, 255, 0.1); - border-color: rgba(147, 51, 234, 0.5); + 0 4px 16px rgba(0, 0, 0, 0.5), + 0 0 40px rgba(147, 51, 234, 0.25), + inset 0 1px 0 rgba(255, 255, 255, 0.05); + border-color: rgba(147, 51, 234, 0.4); } } .progress-content { display: flex; flex-direction: column; - gap: 1.5rem; + gap: 0.75rem; } .progress-info { @@ -1548,7 +1650,7 @@ body { .progress-info span { font-family: 'JetBrains Mono', monospace; - font-size: 0.875rem; + font-size: 0.8rem; } #progressText { @@ -1572,8 +1674,8 @@ body { #progressPercent { color: #9333ea; font-weight: 700; - font-size: 2rem; - text-shadow: 0 0 20px rgba(147, 51, 234, 0.8); + font-size: 1.25rem; + text-shadow: 0 0 15px rgba(147, 51, 234, 0.6); animation: percentGlow 1.5s ease-in-out infinite; } @@ -1592,15 +1694,15 @@ body { } .progress-bar-container { - height: 16px; + height: 10px; background: linear-gradient(90deg, #1f2937, #374151); - border: 2px solid rgba(147, 51, 234, 0.2); - border-radius: 12px; + border: 1px solid rgba(147, 51, 234, 0.2); + border-radius: 8px; overflow: hidden; position: relative; box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.5), - 0 0 20px rgba(147, 51, 234, 0.1); + 0 0 15px rgba(147, 51, 234, 0.1); } .progress-bar-container::before { @@ -1636,15 +1738,15 @@ body { #06b6d4 75%, #10b981 100%); background-size: 200% 100%; - border-radius: 10px; + border-radius: 6px; width: 0%; transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; animation: progressFlow 3s linear infinite; box-shadow: - 0 0 30px rgba(147, 51, 234, 0.6), - inset 0 1px 0 rgba(255, 255, 255, 0.3); + 0 0 20px rgba(147, 51, 234, 0.5), + inset 0 1px 0 rgba(255, 255, 255, 0.2); } @keyframes progressFlow { @@ -1692,6 +1794,71 @@ body { text-shadow: 0 0 5px rgba(156, 163, 175, 0.3); } +/* Installation effects */ +.installation-effects { + position: fixed; + top: 0; + left: 80px; + width: calc(100% - 80px); + height: 100%; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(10px); + z-index: 40; + pointer-events: auto; + overflow: hidden; +} + +.space-effects { + position: absolute; + width: 100%; + height: 100%; + perspective: 1000px; +} + +.warp-line { + position: absolute; + width: 2px; + height: 100%; + background: linear-gradient(180deg, + transparent 0%, + rgba(147, 51, 234, 0.8) 50%, + transparent 100%); + box-shadow: 0 0 10px rgba(147, 51, 234, 0.8), + 0 0 20px rgba(147, 51, 234, 0.4); + animation: warpSpeed 1.5s linear infinite; + opacity: 0; +} + +.warp-line:nth-child(1) { left: 10%; animation-delay: 0s; } +.warp-line:nth-child(2) { left: 25%; animation-delay: 0.2s; } +.warp-line:nth-child(3) { left: 40%; animation-delay: 0.4s; } +.warp-line:nth-child(4) { left: 55%; animation-delay: 0.6s; } +.warp-line:nth-child(5) { left: 70%; animation-delay: 0.8s; } +.warp-line:nth-child(6) { left: 85%; animation-delay: 1s; } +.warp-line:nth-child(7) { left: 15%; animation-delay: 0.3s; } +.warp-line:nth-child(8) { left: 60%; animation-delay: 0.7s; } + +@keyframes warpSpeed { + 0% { + transform: translateY(-100%) scaleY(0); + opacity: 0; + } + 10% { + opacity: 1; + } + 50% { + opacity: 1; + transform: translateY(0%) scaleY(1); + } + 90% { + opacity: 1; + } + 100% { + transform: translateY(100%) scaleY(2); + opacity: 0; + } +} + .mods-manager { display: flex; diff --git a/backend/core/config.js b/backend/core/config.js index 23332a8..03cff49 100644 --- a/backend/core/config.js +++ b/backend/core/config.js @@ -156,6 +156,15 @@ function loadLanguage() { return config.language || 'en'; } +function saveCloseLauncherOnStart(enabled) { + saveConfig({ closeLauncherOnStart: !!enabled }); +} + +function loadCloseLauncherOnStart() { + const config = loadConfig(); + return config.closeLauncherOnStart !== undefined ? config.closeLauncherOnStart : false; +} + function saveModsToConfig(mods) { try { const config = loadConfig(); @@ -331,5 +340,8 @@ module.exports = { resetCurrentUserUuid, // GPU Preference exports saveGpuPreference, - loadGpuPreference + loadGpuPreference, + // Close Launcher export + saveCloseLauncherOnStart, + loadCloseLauncherOnStart }; diff --git a/backend/core/paths.js b/backend/core/paths.js index e5ee8d0..b82de75 100644 --- a/backend/core/paths.js +++ b/backend/core/paths.js @@ -162,13 +162,18 @@ async function getModsPath(customInstallPath = null) { const modsPath = path.join(userDataPath, 'Mods'); const disabledModsPath = path.join(userDataPath, 'DisabledMods'); + const profilesPath = path.join(userDataPath, 'Profiles'); if (!fs.existsSync(modsPath)) { + // Ensure the Mods directory exists fs.mkdirSync(modsPath, { recursive: true }); } if (!fs.existsSync(disabledModsPath)) { fs.mkdirSync(disabledModsPath, { recursive: true }); } + if (!fs.existsSync(profilesPath)) { + fs.mkdirSync(profilesPath, { recursive: true }); + } return modsPath; } catch (error) { @@ -177,6 +182,34 @@ async function getModsPath(customInstallPath = null) { } } +function getProfilesDir(customInstallPath = null) { + try { + // get UserData path + let installPath = customInstallPath; + if (!installPath) { + const configFile = path.join(DEFAULT_APP_DIR, 'config.json'); + if (fs.existsSync(configFile)) { + const config = JSON.parse(fs.readFileSync(configFile, 'utf8')); + installPath = config.installPath || ''; + } + } + if (!installPath) installPath = getAppDir(); + + const gameLatest = path.join(installPath, 'release', 'package', 'game', 'latest'); + const userDataPath = findUserDataPath(gameLatest); + const profilesDir = path.join(userDataPath, 'Profiles'); + + if (!fs.existsSync(profilesDir)) { + fs.mkdirSync(profilesDir, { recursive: true }); + } + + return profilesDir; + } catch (err) { + console.error('Error getting profiles dir:', err); + return null; + } +} + module.exports = { getAppDir, getResolvedAppDir, @@ -191,5 +224,6 @@ module.exports = { findClientPath, findUserDataPath, findUserDataRecursive, - getModsPath + getModsPath, + getProfilesDir }; diff --git a/backend/launcher.js b/backend/launcher.js index cadee5e..32a6c59 100644 --- a/backend/launcher.js +++ b/backend/launcher.js @@ -17,6 +17,8 @@ const { loadDiscordRPC, saveLanguage, loadLanguage, + saveCloseLauncherOnStart, + loadCloseLauncherOnStart, saveModsToConfig, loadModsFromConfig, getUuidForUser, @@ -124,6 +126,10 @@ module.exports = { saveLanguage, loadLanguage, + // Close Launcher functions + saveCloseLauncherOnStart, + loadCloseLauncherOnStart, + // GPU Preference functions saveGpuPreference, loadGpuPreference, diff --git a/backend/managers/modManager.js b/backend/managers/modManager.js index 50c5744..5756e4e 100644 --- a/backend/managers/modManager.js +++ b/backend/managers/modManager.js @@ -2,10 +2,30 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const axios = require('axios'); -const { getModsPath } = require('../core/paths'); +const { getModsPath, getProfilesDir } = require('../core/paths'); const { saveModsToConfig, loadModsFromConfig } = require('../core/config'); const profileManager = require('./profileManager'); +const API_KEY = process.env.CURSEFORGE_API_KEY; + +/** + * Get the physical mods path for a specific profile. + * Each profile now has its own 'mods' folder. + */ +function getProfileModsPath(profileId) { + const profilesDir = getProfilesDir(); + if (!profilesDir) return null; + + const profileDir = path.join(profilesDir, profileId); + const modsDir = path.join(profileDir, 'mods'); + + if (!fs.existsSync(modsDir)) { + fs.mkdirSync(modsDir, { recursive: true }); + } + + return modsDir; +} + function generateModId(filename) { return crypto.createHash('md5').update(filename).digest('hex').substring(0, 8); } @@ -35,30 +55,33 @@ function getProfileMods() { async function loadInstalledMods(modsPath) { try { + // Sync first to ensure we detect any manually added mods and paths are correct + await syncModsForCurrentProfile(); + const activeProfile = profileManager.getActiveProfile(); if (!activeProfile) return []; const profileMods = activeProfile.mods || []; - const profileModFiles = new Set(profileMods.map(m => m.fileName)); - - // We only return mods that are explicitly in the profile - // Check which ones are physically present (either in mods/ or DisabledMods/) - - const physicalModsPath = modsPath; // .../mods - const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods'); + + // Use profile-specific paths + const profileModsPath = getProfileModsPath(activeProfile.id); + const profileDisabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods'); + + if (!fs.existsSync(profileModsPath)) fs.mkdirSync(profileModsPath, { recursive: true }); + if (!fs.existsSync(profileDisabledModsPath)) fs.mkdirSync(profileDisabledModsPath, { recursive: true }); const validMods = []; for (const modConfig of profileMods) { // Check if file exists in either location - const inEnabled = fs.existsSync(path.join(physicalModsPath, modConfig.fileName)); - const inDisabled = fs.existsSync(path.join(disabledModsPath, modConfig.fileName)); + const inEnabled = fs.existsSync(path.join(profileModsPath, modConfig.fileName)); + const inDisabled = fs.existsSync(path.join(profileDisabledModsPath, modConfig.fileName)); if (inEnabled || inDisabled) { validMods.push({ ...modConfig, // Set filePath based on physical location - filePath: inEnabled ? path.join(physicalModsPath, modConfig.fileName) : path.join(disabledModsPath, modConfig.fileName), + filePath: inEnabled ? path.join(profileModsPath, modConfig.fileName) : path.join(profileDisabledModsPath, modConfig.fileName), enabled: modConfig.enabled !== false // Default true }); } else { @@ -82,7 +105,11 @@ async function loadInstalledMods(modsPath) { async function downloadMod(modInfo) { try { - const modsPath = await getModsPath(); + const activeProfile = profileManager.getActiveProfile(); + if (!activeProfile) throw new Error('No active profile to save mod to'); + + const modsPath = getProfileModsPath(activeProfile.id); + if (!modsPath) throw new Error('Could not determine profile mods path'); if (!modInfo.downloadUrl && !modInfo.fileId) { throw new Error('No download URL or file ID provided'); @@ -91,9 +118,9 @@ async function downloadMod(modInfo) { let downloadUrl = modInfo.downloadUrl; if (!downloadUrl && modInfo.fileId && modInfo.modId) { - const response = await axios.get(`https://api.curseforge.com/v1/mods/${modInfo.modId}/files/${modInfo.fileId}`, { + const response = await axios.get(`https://api.curseforge.com/v1/mods/${modInfo.modId || modInfo.curseForgeId}/files/${modInfo.fileId || modInfo.curseForgeFileId}`, { headers: { - 'x-api-key': modInfo.apiKey, + 'x-api-key': modInfo.apiKey || API_KEY, 'Accept': 'application/json' } }); @@ -119,35 +146,30 @@ async function downloadMod(modInfo) { return new Promise((resolve, reject) => { writer.on('finish', () => { - // NEW: Update Active Profile instead of global config - const activeProfile = profileManager.getActiveProfile(); - if (activeProfile) { - const newMod = { - id: modInfo.id || generateModId(fileName), - name: modInfo.name || extractModName(fileName), - version: modInfo.version || '1.0.0', - description: modInfo.summary || modInfo.description || 'Downloaded from CurseForge', - author: modInfo.author || 'Unknown', - enabled: true, - fileName: fileName, - fileSize: fs.statSync(filePath).size, - dateInstalled: new Date().toISOString(), - curseForgeId: modInfo.modId, - curseForgeFileId: modInfo.fileId - }; + // Update Active Profile + const newMod = { + id: modInfo.id || generateModId(fileName), + name: modInfo.name || extractModName(fileName), + version: modInfo.version || '1.0.0', + description: modInfo.summary || modInfo.description || 'Downloaded from CurseForge', + author: modInfo.author || 'Unknown', + enabled: true, + fileName: fileName, + fileSize: fs.statSync(filePath).size, + dateInstalled: new Date().toISOString(), + curseForgeId: modInfo.modId, + curseForgeFileId: modInfo.fileId + }; - const updatedMods = [...(activeProfile.mods || []), newMod]; - profileManager.updateProfile(activeProfile.id, { mods: updatedMods }); + const updatedMods = [...(activeProfile.mods || []), newMod]; + profileManager.updateProfile(activeProfile.id, { mods: updatedMods }); - resolve({ - success: true, - filePath: filePath, - fileName: fileName, - modInfo: newMod - }); - } else { - reject(new Error('No active profile to save mod to')); - } + resolve({ + success: true, + filePath: filePath, + fileName: fileName, + modInfo: newMod + }); }); writer.on('error', reject); }); @@ -173,8 +195,11 @@ async function uninstallMod(modId, modsPath) { throw new Error('Mod not found in profile'); } - const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods'); - const enabledPath = path.join(modsPath, mod.fileName); + // Use profile paths + const profileModsPath = getProfileModsPath(activeProfile.id); + const disabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods'); + + const enabledPath = path.join(profileModsPath, mod.fileName); const disabledPath = path.join(disabledModsPath, mod.fileName); let fileRemoved = false; @@ -226,31 +251,25 @@ async function toggleMod(modId, modsPath) { updatedMods[modIndex] = { ...mod, enabled: newEnabled }; profileManager.updateProfile(activeProfile.id, { mods: updatedMods }); - // Manually move the file to reflect the new state - const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods'); + // Move file between Profile/Mods and Profile/DisabledMods + const profileModsPath = getProfileModsPath(activeProfile.id); + const disabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods'); + if (!fs.existsSync(disabledModsPath)) fs.mkdirSync(disabledModsPath, { recursive: true }); - const currentPath = mod.enabled ? path.join(modsPath, mod.fileName) : path.join(disabledModsPath, mod.fileName); - - // Determine target paths - - const targetDir = newEnabled ? modsPath : disabledModsPath; + const currentPath = mod.enabled ? path.join(profileModsPath, mod.fileName) : path.join(disabledModsPath, mod.fileName); + const targetDir = newEnabled ? profileModsPath : disabledModsPath; const targetPath = path.join(targetDir, mod.fileName); if (fs.existsSync(currentPath)) { fs.renameSync(currentPath, targetPath); } else { // Fallback: check if it's already in target? - - if (fs.existsSync(targetPath)) { - // It's already there, maybe just state was wrong. - console.log(`[ModManager] Mod ${mod.fileName} is already in the correct state`); - } else { // Try finding it - const altPath = mod.enabled ? path.join(disabledModsPath, mod.fileName) : path.join(modsPath, mod.fileName); + const altPath = mod.enabled ? path.join(disabledModsPath, mod.fileName) : path.join(profileModsPath, mod.fileName); if (fs.existsSync(altPath)) fs.renameSync(altPath, targetPath); } } @@ -273,35 +292,166 @@ async function syncModsForCurrentProfile() { return; } - console.log(`[ModManager] Syncing mods for profile: ${activeProfile.name}`); + console.log(`[ModManager] Syncing mods for profile: ${activeProfile.name} (${activeProfile.id})`); - const modsPath = await getModsPath(); - const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods'); + // 1. Resolve Paths + // globalModsPath is the one the game uses (symlink target) + const globalModsPath = await getModsPath(); + // profileModsPath is the real storage for this profile + const profileModsPath = getProfileModsPath(activeProfile.id); + const profileDisabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods'); - if (!fs.existsSync(disabledModsPath)) { - fs.mkdirSync(disabledModsPath, { recursive: true }); + if (!fs.existsSync(profileDisabledModsPath)) { + fs.mkdirSync(profileDisabledModsPath, { recursive: true }); } - // Get all physical files from both folders - const enabledFiles = fs.existsSync(modsPath) ? fs.readdirSync(modsPath).filter(f => f.endsWith('.jar') || f.endsWith('.zip')) : []; - const disabledFiles = fs.existsSync(disabledModsPath) ? fs.readdirSync(disabledModsPath).filter(f => f.endsWith('.jar') || f.endsWith('.zip')) : []; + // 2. Symlink / Migration Logic + let needsLink = false; + if (fs.existsSync(globalModsPath)) { + const stats = fs.lstatSync(globalModsPath); + + if (stats.isSymbolicLink()) { + const linkTarget = fs.readlinkSync(globalModsPath); + // Normalize paths for comparison + if (path.resolve(linkTarget) !== path.resolve(profileModsPath)) { + console.log(`[ModManager] Updating symlink from ${linkTarget} to ${profileModsPath}`); + fs.unlinkSync(globalModsPath); + needsLink = true; + } + } else if (stats.isDirectory()) { + // MIGRATION: It's a real directory. Move contents to profile. + console.log('[ModManager] Migrating global mods folder to profile folder...'); + const files = fs.readdirSync(globalModsPath); + for (const file of files) { + const src = path.join(globalModsPath, file); + const dest = path.join(profileModsPath, file); + // Only move if dest doesn't exist to avoid overwriting + if (!fs.existsSync(dest)) { + fs.renameSync(src, dest); + } + } + + // Also migrate DisabledMods if it exists globally + const globalDisabledPath = path.join(path.dirname(globalModsPath), 'DisabledMods'); + if (fs.existsSync(globalDisabledPath) && fs.lstatSync(globalDisabledPath).isDirectory()) { + const dFiles = fs.readdirSync(globalDisabledPath); + for (const file of dFiles) { + const src = path.join(globalDisabledPath, file); + const dest = path.join(profileDisabledModsPath, file); + if (!fs.existsSync(dest)) { + fs.renameSync(src, dest); + } + } + // We can remove global DisabledMods now, as it's not used by game + try { fs.rmSync(globalDisabledPath, { recursive: true, force: true }); } catch(e) {} + } + + // Remove the directory so we can link it + try { + fs.rmSync(globalModsPath, { recursive: true, force: true }); + needsLink = true; + } catch (e) { + console.error('Failed to remove global mods dir:', e); + // Throw error to stop. + throw new Error('Failed to migrate mods directory. Please clear ' + globalModsPath); + } + } + } else { + needsLink = true; + } + + if (needsLink) { + console.log(`[ModManager] Creating symlink: ${globalModsPath} -> ${profileModsPath}`); + try { + // 'junction' is key for Windows without admin + fs.symlinkSync(profileModsPath, globalModsPath, 'junction'); + } catch (err) { + // If we can't create the symlink, try creating the directory first + console.error('[ModManager] Failed to create symlink. Falling back to direct folder mode.'); + console.error(err.message); + + // Fallback: create a real directory so the game still works + if (!fs.existsSync(globalModsPath)) { + fs.mkdirSync(globalModsPath, { recursive: true }); + } + } + } + + // 3. Auto-Repair (Download missing mods) + const profileModsSnapshot = activeProfile.mods || []; + for (const mod of profileModsSnapshot) { + if (mod.enabled && !mod.manual) { + const inEnabled = fs.existsSync(path.join(profileModsPath, mod.fileName)); + const inDisabled = fs.existsSync(path.join(profileDisabledModsPath, mod.fileName)); + + if (!inEnabled && !inDisabled) { + if (mod.curseForgeId && (mod.curseForgeFileId || mod.fileId)) { + console.log(`[ModManager] Auto-repair: Re-downloading missing mod "${mod.name}"...`); + try { + await downloadMod({ + ...mod, + modId: mod.curseForgeId, + fileId: mod.curseForgeFileId || mod.fileId, + apiKey: API_KEY + }); + } catch (err) { + console.error(`[ModManager] Auto-repair failed for "${mod.name}": ${err.message}`); + } + } + } + } + } + + // 4. Auto-Import (Detect manual drops in the profile folder) + const enabledFiles = fs.existsSync(profileModsPath) ? fs.readdirSync(profileModsPath).filter(f => f.endsWith('.jar') || f.endsWith('.zip')) : []; + + let profileMods = activeProfile.mods || []; + let profileUpdated = false; + + + // Anything in this folder belongs to this profile. + + for (const file of enabledFiles) { + const isKnown = profileMods.some(m => m.fileName === file); + + if (!isKnown) { + console.log(`[ModManager] Auto-importing manual mod: ${file}`); + const newMod = { + id: generateModId(file), + name: extractModName(file), + version: 'Unknown', + description: 'Manually installed', + author: 'Local', + enabled: true, + fileName: file, + fileSize: 0, + dateInstalled: new Date().toISOString(), + manual: true + }; + profileMods.push(newMod); + profileUpdated = true; + } + } + + if (profileUpdated) { + profileManager.updateProfile(activeProfile.id, { mods: profileMods }); + const updatedProfile = profileManager.getActiveProfile(); + profileMods = updatedProfile ? (updatedProfile.mods || []) : profileMods; + } + + // 5. Enforce Enabled/Disabled State (Move files between Profile/Mods and Profile/DisabledMods) + // Note: Since Global/Mods IS Profile/Mods (via symlink), moving out of Profile/Mods disables it for the game. + + const disabledFiles = fs.existsSync(profileDisabledModsPath) ? fs.readdirSync(profileDisabledModsPath).filter(f => f.endsWith('.jar') || f.endsWith('.zip')) : []; const allFiles = new Set([...enabledFiles, ...disabledFiles]); - // Profile.mods contains the list of ALL mods for that profile, with their enabled state. - - const profileMods = activeProfile.mods || []; - for (const fileName of allFiles) { const modConfig = profileMods.find(m => m.fileName === fileName); - const shouldBeEnabled = modConfig && modConfig.enabled !== false; // Default to true if in list, unless explicitly false + const shouldBeEnabled = modConfig && modConfig.enabled !== false; - // Logic: - // If it should be enabled -> Move to mods/ - // If it should be disabled -> Move to DisabledMods/ - - const currentPath = enabledFiles.includes(fileName) ? path.join(modsPath, fileName) : path.join(disabledModsPath, fileName); - const targetDir = shouldBeEnabled ? modsPath : disabledModsPath; + const currentPath = enabledFiles.includes(fileName) ? path.join(profileModsPath, fileName) : path.join(profileDisabledModsPath, fileName); + const targetDir = shouldBeEnabled ? profileModsPath : profileDisabledModsPath; const targetPath = path.join(targetDir, fileName); if (path.dirname(currentPath) !== targetDir) { diff --git a/main.js b/main.js index 3e1b914..9fe72d6 100644 --- a/main.js +++ b/main.js @@ -1,19 +1,36 @@ -const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron'); const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '.env') }); +const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron'); const fs = require('fs'); -const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveChatUsername, loadChatUsername, saveChatColor, loadChatColor, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, saveDiscordRPC, loadDiscordRPC, saveLanguage, loadLanguage, isGameInstalled, uninstallGame, repairGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched } = require('./backend/launcher'); -const AppUpdater = require('./backend/appUpdater'); +const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveChatUsername, loadChatUsername, saveChatColor, loadChatColor, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, saveDiscordRPC, loadDiscordRPC, saveLanguage, loadLanguage, saveCloseLauncherOnStart, loadCloseLauncherOnStart, isGameInstalled, uninstallGame, repairGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched } = require('./backend/launcher'); + +const UpdateManager = require('./backend/updateManager'); const logger = require('./backend/logger'); const profileManager = require('./backend/managers/profileManager'); logger.interceptConsole(); +// Single instance lock +const gotTheLock = app.requestSingleInstanceLock(); + +if (!gotTheLock) { + console.log('Another instance is already running. Quitting...'); + app.quit(); +} else { + app.on('second-instance', (event, commandLine, workingDirectory) => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); +} + let mainWindow; -let appUpdater; +let updateManager; let discordRPC = null; // Discord Rich Presence setup -const DISCORD_CLIENT_ID = '1462244937868513373'; +const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID; function initDiscordRPC() { try { @@ -80,19 +97,47 @@ function toggleDiscordRPC(enabled) { console.log('Discord RPC disconnected successfully'); } catch (error) { console.error('Error disconnecting Discord RPC:', error.message); - discordRPC = null; // Force null même en cas d'erreur + discordRPC = null; } } } +function createSplashScreen() { + const splashWindow = new BrowserWindow({ + width: 500, + height: 350, + frame: false, + transparent: true, + alwaysOnTop: true, + resizable: false, + skipTaskbar: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true + } + }); + + splashWindow.loadFile('GUI/splash.html'); + splashWindow.center(); + + // close splash after 2.5s , need to implement a files check or whatever. just mock for now + setTimeout(() => { + splashWindow.close(); + createWindow(); + }, 2500); +} + function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 720, + minWidth: 900, + minHeight: 600, frame: false, - resizable: false, + resizable: true, alwaysOnTop: false, backgroundColor: '#090909', + show: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, @@ -104,6 +149,10 @@ function createWindow() { mainWindow.loadFile('GUI/index.html'); + mainWindow.once('ready-to-show', () => { + mainWindow.show(); + }); + // Cleanup Discord RPC when window is closed mainWindow.on('closed', () => { console.log('Main window closed, cleaning up Discord RPC...'); @@ -113,13 +162,11 @@ function createWindow() { // Initialize Discord Rich Presence initDiscordRPC(); - // Initialize App Updater - appUpdater = new AppUpdater(mainWindow); - - // Check for updates after a short delay (3 seconds) - setTimeout(() => { - if (appUpdater) { - appUpdater.checkForUpdatesAndNotify(); + updateManager = new UpdateManager(); + setTimeout(async () => { + const updateInfo = await updateManager.checkForUpdates(); + if (updateInfo.updateAvailable) { + mainWindow.webContents.send('show-update-popup', updateInfo); } }, 3000); @@ -140,10 +187,21 @@ function createWindow() { if (input.key === 'F12') { event.preventDefault(); } - if (input.key === 'F5') { - event.preventDefault(); - } - }); + if (input.key === 'F5') { + event.preventDefault(); + } + + // Close application shortcuts + const isMac = process.platform === 'darwin'; + const quitShortcut = (isMac && input.meta && input.key.toLowerCase() === 'q') || + (!isMac && input.control && input.key.toLowerCase() === 'q') || + (!isMac && input.alt && input.key === 'F4'); + + if (quitShortcut) { + app.quit(); + } + }); + mainWindow.webContents.on('context-menu', (e) => { @@ -154,7 +212,9 @@ function createWindow() { } app.whenReady().then(async () => { + const packageJson = require('./package.json'); console.log('=== HYTALE F2P LAUNCHER STARTED ==='); + console.log('Launcher version:', packageJson.version); console.log('Platform:', process.platform); console.log('Architecture:', process.arch); console.log('Electron version:', process.versions.electron); @@ -179,7 +239,7 @@ app.whenReady().then(async () => { // Initialize Profile Manager (runs migration if needed) profileManager.init(); - createWindow(); + createSplashScreen(); setTimeout(async () => { let timeoutReached = false; @@ -285,15 +345,14 @@ app.on('before-quit', () => { cleanupDiscordRPC(); }); -app.on('window-all-closed', () => { - console.log('=== LAUNCHER CLOSING ==='); - - cleanupDiscordRPC(); - - if (process.platform !== 'darwin') { - app.quit(); - } -}); +app.on('window-all-closed', () => { + console.log('=== LAUNCHER CLOSING ==='); + + cleanupDiscordRPC(); + + app.quit(); +}); + ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, gpuPreference) => { try { @@ -310,9 +369,20 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g } }; - const result = await launchGameWithVersionCheck(playerName, progressCallback, javaPath, installPath, gpuPreference); - - return result; + const result = await launchGameWithVersionCheck(playerName, progressCallback, javaPath, installPath, gpuPreference); + + if (result.success && result.launched) { + const closeOnStart = loadCloseLauncherOnStart(); + if (closeOnStart) { + console.log('Close Launcher on start enabled, quitting application...'); + setTimeout(() => { + app.quit(); + }, 1000); + } + } + + return result; + } catch (error) { console.error('Launch error:', error); const errorMessage = error.message || error.toString(); @@ -329,6 +399,11 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g ipcMain.handle('install-game', async (event, playerName, javaPath, installPath) => { try { + // Signal installation start + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('installation-start'); + } + const progressCallback = (message, percent, speed, downloaded, total) => { if (mainWindow && !mainWindow.isDestroyed()) { const data = { @@ -344,11 +419,21 @@ ipcMain.handle('install-game', async (event, playerName, javaPath, installPath) const result = await installGame(playerName, progressCallback, javaPath, installPath); + // Signal installation end + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('installation-end'); + } + return result; } catch (error) { console.error('Install error:', error); const errorMessage = error.message || error.toString(); + // Signal installation end on error too + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('installation-end'); + } + return { success: false, error: errorMessage }; } }); @@ -412,11 +497,21 @@ ipcMain.handle('save-language', (event, language) => { return { success: true }; }); -ipcMain.handle('load-language', () => { - return loadLanguage(); -}); - -ipcMain.handle('select-install-path', async () => { +ipcMain.handle('load-language', () => { + return loadLanguage(); +}); + +ipcMain.handle('save-close-launcher', (event, enabled) => { + saveCloseLauncherOnStart(enabled); + return { success: true }; +}); + +ipcMain.handle('load-close-launcher', () => { + return loadCloseLauncherOnStart(); +}); + +ipcMain.handle('select-install-path', async () => { + const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'], title: 'Select Installation Folder' @@ -628,6 +723,10 @@ ipcMain.handle('get-local-app-data', async () => { return process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); }); +ipcMain.handle('get-env-var', async (event, key) => { + return process.env[key]; +}); + ipcMain.handle('get-user-id', async () => { try { const { getOrCreatePlayerId } = require('./backend/launcher'); @@ -726,61 +825,21 @@ ipcMain.handle('copy-mod-file', async (event, sourcePath, modsPath) => { ipcMain.handle('check-for-updates', async () => { try { - if (appUpdater) { - const result = await appUpdater.checkForUpdates(); - const currentVersion = app.getVersion(); - const remoteVersion = result?.updateInfo?.version; - - // Only show update if remote version is actually newer than current - const updateAvailable = remoteVersion && - remoteVersion !== currentVersion && - isVersionNewer(remoteVersion, currentVersion); - - return { - updateAvailable: updateAvailable, - version: remoteVersion, - newVersion: remoteVersion, - currentVersion: currentVersion - }; - } - return { updateAvailable: false, error: 'AppUpdater not initialized' }; + return await updateManager.checkForUpdates(); } catch (error) { console.error('Error checking for updates:', error); return { updateAvailable: false, error: error.message }; } }); -// Helper function to compare semantic versions -function isVersionNewer(version1, version2) { - // Simple semantic version comparison - // Remove any non-numeric suffixes for comparison - const v1Parts = version1.replace(/[^0-9.]/g, '').split('.').map(Number); - const v2Parts = version2.replace(/[^0-9.]/g, '').split('.').map(Number); - - // Pad arrays to same length - const maxLength = Math.max(v1Parts.length, v2Parts.length); - while (v1Parts.length < maxLength) v1Parts.push(0); - while (v2Parts.length < maxLength) v2Parts.push(0); - - // Compare each part - for (let i = 0; i < maxLength; i++) { - if (v1Parts[i] > v2Parts[i]) return true; - if (v1Parts[i] < v2Parts[i]) return false; - } - - return false; // Versions are equal -} - ipcMain.handle('open-download-page', async () => { try { - // Open GitHub releases page - await shell.openExternal('https://github.com/amiayweb/Hytale-F2P/releases'); + await shell.openExternal(updateManager.getDownloadUrl()); - setTimeout(() => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.close(); - } - }, 1000); + setTimeout(() => { + app.quit(); + }, 1000); + return { success: true }; } catch (error) { @@ -789,24 +848,8 @@ ipcMain.handle('open-download-page', async () => { } }); -ipcMain.handle('quit-and-install-update', async () => { - try { - if (appUpdater) { - appUpdater.quitAndInstall(); - return { success: true }; - } - return { success: false, error: 'AppUpdater not initialized' }; - } catch (error) { - console.error('Error installing update:', error); - return { success: false, error: error.message }; - } -}); - ipcMain.handle('get-update-info', async () => { - if (appUpdater) { - return appUpdater.getUpdateInfo(); - } - return { currentVersion: app.getVersion(), updateAvailable: false }; + return updateManager.getUpdateInfo(); }); ipcMain.handle('get-gpu-info', () => { @@ -838,11 +881,10 @@ ipcMain.handle('get-detected-gpu', () => { return global.detectedGpu; }); -ipcMain.handle('window-close', () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.close(); - } -}); +ipcMain.handle('window-close', () => { + app.quit(); +}); + ipcMain.handle('window-minimize', () => { if (mainWindow && !mainWindow.isDestroyed()) { @@ -850,6 +892,21 @@ ipcMain.handle('window-minimize', () => { } }); +ipcMain.handle('window-maximize', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + } +}); + +ipcMain.handle('get-version', () => { + const packageJson = require('./package.json'); + return packageJson.version; +}); + ipcMain.handle('get-log-directory', () => { return logger.getLogDirectory(); }); diff --git a/package.json b/package.json index 62e6f72..26e43a6 100644 --- a/package.json +++ b/package.json @@ -1,148 +1,150 @@ -{ - "name": "hytale-f2p-launcher", - "version": "2.0.11", - "description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support", - "homepage": "https://github.com/amiayweb/Hytale-F2P", - "main": "main.js", - "scripts": { - "start": "electron .", - "dev": "electron . --dev", - "build": "electron-builder", - "build:win": "electron-builder --win", - "build:linux": "electron-builder --linux", - "build:mac": "electron-builder --mac", - "build:all": "electron-builder --win --linux --mac" - }, - "keywords": [ - "hytale", - "launcher", - "game", - "client", - "cross-platform", - "electron", - "auto-update", - "mod-manager", - "chat" - ], - "maintainers": [ - { - "name": "Terromur", - "url": "https://github.com/Terromur" - }, - { - "name": "Fari Gading", - "email": "fazrigading@gmail.com", - "url": "https://github.com/fazrigading" - } - ], - "author": { - "name": "AMIAY", - "email": "support@amiay.dev" - }, - "license": "MIT", - "devDependencies": { - "electron": "^40.0.0", - "electron-builder": "^26.4.0" - }, - "dependencies": { - "adm-zip": "^0.5.10", - "axios": "^1.6.0", - "discord-rpc": "^4.0.1", - "electron-updater": "^6.7.3", - "tar": "^6.2.1", - "uuid": "^9.0.1" - }, - "overrides": { - "tar": "$tar" - }, - "build": { - "appId": "com.hytalef2p.launcher", - "productName": "Hytale F2P Launcher", - "artifactName": "${name}_${version}_${arch}.${ext}", - "directories": { - "output": "dist" - }, - "files": [ - "main.js", - "preload.js", - "backend/**/*", - "GUI/**/*", - "package.json" - ], - "win": { - "target": [ - { - "target": "nsis", - "arch": [ - "x64", - "arm64" - ] - } - ], - "icon": "icon.ico" - }, - "linux": { - "target": [ - { - "target": "AppImage", - "arch": [ - "x64", - "arm64" - ] - }, - { - "target": "deb", - "arch": [ - "x64", - "arm64" - ] - }, - { - "target": "rpm", - "arch": [ - "x64", - "arm64" - ] - }, - { - "target": "pacman", - "arch": [ - "x64", - "arm64" - ] - } - ], - "icon": "build/icon.png", - "category": "Game" - }, - "mac": { - "target": [ - { - "target": "dmg", - "arch": [ - "universal" - ] - }, - { - "target": "zip", - "arch": [ - "universal" - ] - } - ], - "icon": "build/icon.icns", - "category": "public.app-category.games" - }, - "nsis": { - "oneClick": false, - "allowToChangeInstallationDirectory": true, - "createDesktopShortcut": true, - "createStartMenuShortcut": true - }, - "publish": { - "provider": "github", - "owner": "amiayweb", - "repo": "Hytale-F2P" - } - } -} +{ + "name": "hytale-f2p-launcher", + "version": "2.0.11", + "description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support", + "homepage": "https://github.com/amiayweb/Hytale-F2P", + "main": "main.js", + "scripts": { + "start": "electron .", + "dev": "electron . --dev", + "build": "electron-builder", + "build:win": "electron-builder --win", + "build:linux": "electron-builder --linux", + "build:mac": "electron-builder --mac", + "build:all": "electron-builder --win --linux --mac" + }, + "keywords": [ + "hytale", + "launcher", + "game", + "client", + "cross-platform", + "electron", + "auto-update", + "mod-manager", + "chat" + ], + "maintainers": [ + { + "name": "Terromur", + "url": "https://github.com/Terromur" + }, + { + "name": "Fari Gading", + "email": "fazrigading@gmail.com", + "url": "https://github.com/fazrigading" + } + ], + "author": { + "name": "AMIAY", + "email": "support@amiay.dev" + }, + "license": "MIT", + "devDependencies": { + "electron": "^40.0.0", + "electron-builder": "^26.4.0" + }, + "dependencies": { + "adm-zip": "^0.5.10", + "axios": "^1.6.0", + "discord-rpc": "^4.0.1", + "dotenv": "^17.2.3", + "electron-updater": "^6.7.3", + "tar": "^6.2.1", + "uuid": "^9.0.1" + }, + "overrides": { + "tar": "$tar" + }, + "build": { + "appId": "com.hytalef2p.launcher", + "productName": "Hytale F2P Launcher", + "artifactName": "${name}_${version}_${arch}.${ext}", + "directories": { + "output": "dist" + }, + "files": [ + "main.js", + "preload.js", + "backend/**/*", + "GUI/**/*", + "package.json", + ".env" + ], + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64", + "arm64" + ] + } + ], + "icon": "icon.ico" + }, + "linux": { + "target": [ + { + "target": "AppImage", + "arch": [ + "x64", + "arm64" + ] + }, + { + "target": "deb", + "arch": [ + "x64", + "arm64" + ] + }, + { + "target": "rpm", + "arch": [ + "x64", + "arm64" + ] + }, + { + "target": "pacman", + "arch": [ + "x64", + "arm64" + ] + } + ], + "icon": "build/icon.png", + "category": "Game" + }, + "mac": { + "target": [ + { + "target": "dmg", + "arch": [ + "universal" + ] + }, + { + "target": "zip", + "arch": [ + "universal" + ] + } + ], + "icon": "build/icon.icns", + "category": "public.app-category.games" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true + }, + "publish": { + "provider": "github", + "owner": "amiayweb", + "repo": "Hytale-F2P" + } + } +} diff --git a/preload.js b/preload.js index bc6e86e..b00d840 100644 --- a/preload.js +++ b/preload.js @@ -5,6 +5,8 @@ contextBridge.exposeInMainWorld('electronAPI', { installGame: (playerName, javaPath, installPath) => ipcRenderer.invoke('install-game', playerName, javaPath, installPath), closeWindow: () => ipcRenderer.invoke('window-close'), minimizeWindow: () => ipcRenderer.invoke('window-minimize'), + maximizeWindow: () => ipcRenderer.invoke('window-maximize'), + getVersion: () => ipcRenderer.invoke('get-version'), saveUsername: (username) => ipcRenderer.invoke('save-username', username), loadUsername: () => ipcRenderer.invoke('load-username'), saveChatUsername: (chatUsername) => ipcRenderer.invoke('save-chat-username', chatUsername), @@ -19,6 +21,8 @@ contextBridge.exposeInMainWorld('electronAPI', { loadDiscordRPC: () => ipcRenderer.invoke('load-discord-rpc'), saveLanguage: (language) => ipcRenderer.invoke('save-language', language), loadLanguage: () => ipcRenderer.invoke('load-language'), + saveCloseLauncher: (enabled) => ipcRenderer.invoke('save-close-launcher', enabled), + loadCloseLauncher: () => ipcRenderer.invoke('load-close-launcher'), selectInstallPath: () => ipcRenderer.invoke('select-install-path'), browseJavaPath: () => ipcRenderer.invoke('browse-java-path'), isGameInstalled: () => ipcRenderer.invoke('is-game-installed'), @@ -30,6 +34,7 @@ contextBridge.exposeInMainWorld('electronAPI', { openGameLocation: () => ipcRenderer.invoke('open-game-location'), saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings), loadSettings: () => ipcRenderer.invoke('load-settings'), + getEnvVar: (key) => ipcRenderer.invoke('get-env-var', key), getLocalAppData: () => ipcRenderer.invoke('get-local-app-data'), getModsPath: () => ipcRenderer.invoke('get-mods-path'), loadInstalledMods: (modsPath) => ipcRenderer.invoke('load-installed-mods', modsPath), @@ -44,6 +49,12 @@ contextBridge.exposeInMainWorld('electronAPI', { onProgressComplete: (callback) => { ipcRenderer.on('progress-complete', () => callback()); }, + onInstallationStart: (callback) => { + ipcRenderer.on('installation-start', () => callback()); + }, + onInstallationEnd: (callback) => { + ipcRenderer.on('installation-end', () => callback()); + }, getUserId: () => ipcRenderer.invoke('get-user-id'), checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), openDownloadPage: () => ipcRenderer.invoke('open-download-page'), @@ -51,20 +62,7 @@ contextBridge.exposeInMainWorld('electronAPI', { onUpdatePopup: (callback) => { ipcRenderer.on('show-update-popup', (event, data) => callback(data)); }, - onUpdateAvailable: (callback) => { - ipcRenderer.on('update-available', (event, data) => callback(data)); - }, - onUpdateDownloadProgress: (callback) => { - ipcRenderer.on('update-download-progress', (event, data) => callback(data)); - }, - onUpdateDownloaded: (callback) => { - ipcRenderer.on('update-downloaded', (event, data) => callback(data)); - }, - onUpdateError: (callback) => { - ipcRenderer.on('update-error', (event, data) => callback(data)); - }, - quitAndInstallUpdate: () => ipcRenderer.invoke('quit-and-install-update'), - + getGpuInfo: () => ipcRenderer.invoke('get-gpu-info'), saveGpuPreference: (gpuPreference) => ipcRenderer.invoke('save-gpu-preference', gpuPreference), loadGpuPreference: () => ipcRenderer.invoke('load-gpu-preference'),