From 2a024b61ddea7c94c301bc97d94e46d7d0572989 Mon Sep 17 00:00:00 2001 From: AMIAY Date: Thu, 22 Jan 2026 07:41:35 +0100 Subject: [PATCH 1/6] Add installation effects and draggable progress bar Introduces animated installation effects overlay and makes the progress bar draggable. Adds maximize window support, improves window controls styling, and enforces a single app instance. Removes the unused Skins page and related translations. Refines various UI details for a more polished user experience. --- GUI/index.html | 36 ++++---- GUI/js/install.js | 28 +++++- GUI/js/ui.js | 90 +++++++++++++++++++ GUI/locales/en.json | 10 +-- GUI/locales/es.json | 10 +-- GUI/locales/pt-BR.json | 11 +-- GUI/style.css | 196 +++++++++++++++++++++++++++++++---------- main.js | 45 +++++++++- preload.js | 7 ++ 9 files changed, 341 insertions(+), 92 deletions(-) diff --git a/GUI/index.html b/GUI/index.html index eb7b8e7..22fd986 100644 --- a/GUI/index.html +++ b/GUI/index.html @@ -51,11 +51,7 @@ Settings - -
@@ -114,7 +110,7 @@

- HYTALE + HYTALE

FREE TO PLAY LAUNCHER

@@ -462,14 +458,6 @@
-
-
- -

Skins

-

Skin customization coming soon...

-
-
-
@@ -532,6 +520,20 @@
+ + + 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/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/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 ddd8091..e0ab2d0 100644 --- a/main.js +++ b/main.js @@ -1,5 +1,6 @@ -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 UpdateManager = require('./backend/updateManager'); @@ -28,7 +29,7 @@ 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 { @@ -690,6 +691,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'); diff --git a/package.json b/package.json index 51dd605..2abb38f 100644 --- a/package.json +++ b/package.json @@ -1,103 +1,150 @@ -{ - "name": "hytale-f2p-launcher", - "version": "2.0.2b", - "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", - "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"] }, - { "target": "portable", "arch": ["x64"] } - ], - "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 - } - } +{ + "name": "hytale-f2p-launcher", + "version": "2.0.2b", + "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", + "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" + ] + }, + { + "target": "portable", + "arch": [ + "x64" + ] + } + ], + "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 + } + } } \ No newline at end of file diff --git a/preload.js b/preload.js index 84f4ed0..759eb5e 100644 --- a/preload.js +++ b/preload.js @@ -32,6 +32,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), @@ -59,7 +60,7 @@ contextBridge.exposeInMainWorld('electronAPI', { onUpdatePopup: (callback) => { ipcRenderer.on('show-update-popup', (event, data) => callback(data)); }, - + getGpuInfo: () => ipcRenderer.invoke('get-gpu-info'), saveGpuPreference: (gpuPreference) => ipcRenderer.invoke('save-gpu-preference', gpuPreference), loadGpuPreference: () => ipcRenderer.invoke('load-gpu-preference'), From 68d697576a35731a8105b619c6ccdf59dab656ac Mon Sep 17 00:00:00 2001 From: Arnav Singh <72737311+ArnavSingh77@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:41:16 +0530 Subject: [PATCH 6/6] feat: add 'Close launcher on game start' option and improve app termination behavior (#93) * update main branch to release/v2.0.2b (#86) * add more linux pkgs, create auto-release and pre-release feature for Github Actions * removed package-lock from gitignore * update .gitignore for local build * add package-lock.json to maintain stability development * update version to 2.0.2b also add deps for rpm and arch * update 2.0.2b: add arm64 support, product and executable name, maintainers; remove snap; * update 2.0.2b: add latest.yml for win & linux, arm64 support; remove snap * fix release build naming * Prepare release v2.0.2b * feat: add 'Close launcher on game start' option and improve app termination behavior - Added 'Close launcher on game start' setting in GUI and backend. - Implemented automatic app quit after game launch if setting is enabled. - Added Cmd+Q (Mac) and Ctrl+Q/Alt+F4 (Win/Linux) shortcuts to quit the app. - Updated 'window-close' handler to fully quit the app instead of just closing the window. - Added i18n support for the new setting in English, Spanish, and Portuguese. --------- Co-authored-by: Fazri Gading Co-authored-by: Arnav Singh --- .gitignore | 3 +- GUI/index.html | 21 ++++++++++ GUI/js/settings.js | 92 ++++++++++++++++++++++++++++------------- GUI/locales/en.json | 5 ++- GUI/locales/es.json | 5 ++- GUI/locales/pt-BR.json | 5 ++- backend/core/config.js | 14 ++++++- backend/launcher.js | 6 +++ main.js | 94 ++++++++++++++++++++++++++++-------------- package-lock.json | 4 +- preload.js | 2 + 11 files changed, 184 insertions(+), 67 deletions(-) diff --git a/.gitignore b/.gitignore index e578779..b533c73 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ pkg/ # Package files *.tar.zst +*.zst.DS_Store *.zst bun.lockb -.env \ No newline at end of file +.env diff --git a/GUI/index.html b/GUI/index.html index 843aaad..c1d0399 100644 --- a/GUI/index.html +++ b/GUI/index.html @@ -431,6 +431,27 @@
+
+

+ + Launcher Behavior +

+ +
+ +
+
+ +

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/locales/en.json b/GUI/locales/en.json index b7981be..d142831 100644 --- a/GUI/locales/en.json +++ b/GUI/locales/en.json @@ -122,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", diff --git a/GUI/locales/es.json b/GUI/locales/es.json index 283108b..4bb89c8 100644 --- a/GUI/locales/es.json +++ b/GUI/locales/es.json @@ -122,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", diff --git a/GUI/locales/pt-BR.json b/GUI/locales/pt-BR.json index e48c1b0..492440b 100644 --- a/GUI/locales/pt-BR.json +++ b/GUI/locales/pt-BR.json @@ -122,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", 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/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/main.js b/main.js index e0ab2d0..9fe72d6 100644 --- a/main.js +++ b/main.js @@ -2,7 +2,8 @@ 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 { 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'); @@ -186,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) => { @@ -333,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 { @@ -358,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(); @@ -475,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' @@ -804,11 +836,10 @@ ipcMain.handle('open-download-page', async () => { try { await shell.openExternal(updateManager.getDownloadUrl()); - setTimeout(() => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.close(); - } - }, 1000); + setTimeout(() => { + app.quit(); + }, 1000); + return { success: true }; } catch (error) { @@ -850,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()) { diff --git a/package-lock.json b/package-lock.json index 90d6855..bb1bcd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "hytale-f2p-launcherv2", + "name": "hytale-f2p-launcher", "version": "2.0.2b", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "hytale-f2p-launcherv2", + "name": "hytale-f2p-launcher", "version": "2.0.2b", "license": "MIT", "dependencies": { diff --git a/preload.js b/preload.js index 759eb5e..b00d840 100644 --- a/preload.js +++ b/preload.js @@ -21,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'),