mirror of
https://github.com/amiayweb/Hytale-F2P.git
synced 2026-02-26 10:51:44 -03:00
Custom Mod loading fix (#92)
* feat: Add Repair Game functionality including UserData backup and cache clearing * feat: Add In-App Logs Viewer and Logs Folder shortcut * feat: Add Open Logs feature * disable dev tools * Fix Settings UI * Implement custom mod loading, autoimport, auto repair * Fixed Custom Mod loading issues and merge issues * feat: Externalize sensitive API keys and Discord client ID into environment variables using dotenv. * feat(mods): add profile-based mod management and auto-repair
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
CURSEFORGE_API_KEY=$1234asdxXXXXXXkQCXXXXXXXXXXASDb32
|
||||||
|
DISCORD_CLIENT_ID=561263XXXXXX
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,4 +8,6 @@ pkg/
|
|||||||
|
|
||||||
# Package files
|
# Package files
|
||||||
*.tar.zst
|
*.tar.zst
|
||||||
*.zst
|
*.zst
|
||||||
|
bun.lockb
|
||||||
|
.env
|
||||||
138
GUI/index.html
138
GUI/index.html
@@ -118,22 +118,26 @@
|
|||||||
<div class="install-form">
|
<div class="install-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" data-i18n="install.playerName">Player Name</label>
|
<label class="form-label" data-i18n="install.playerName">Player Name</label>
|
||||||
<input type="text" id="installPlayerName" data-i18n-placeholder="install.playerNamePlaceholder"
|
<input type="text" id="installPlayerName"
|
||||||
class="form-input" value="Player" />
|
data-i18n-placeholder="install.playerNamePlaceholder" class="form-input"
|
||||||
|
value="Player" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="checkbox-group">
|
<label class="checkbox-group">
|
||||||
<input type="checkbox" id="installCustomCheck" class="custom-checkbox">
|
<input type="checkbox" id="installCustomCheck" class="custom-checkbox">
|
||||||
<span class="checkbox-label" data-i18n="install.customInstallation">Custom Installation</span>
|
<span class="checkbox-label" data-i18n="install.customInstallation">Custom
|
||||||
|
Installation</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div id="installCustomOptions" class="custom-options">
|
<div id="installCustomOptions" class="custom-options">
|
||||||
<div class="form-subgroup">
|
<div class="form-subgroup">
|
||||||
<label class="form-label" data-i18n="install.installationFolder">Installation Folder</label>
|
<label class="form-label" data-i18n="install.installationFolder">Installation
|
||||||
|
Folder</label>
|
||||||
<div class="input-with-button">
|
<div class="input-with-button">
|
||||||
<input type="text" id="installPath" data-i18n-placeholder="install.pathPlaceholder"
|
<input type="text" id="installPath"
|
||||||
class="form-input" readonly />
|
data-i18n-placeholder="install.pathPlaceholder" class="form-input"
|
||||||
|
readonly />
|
||||||
<button onclick="browseInstallPath()" class="browse-btn">
|
<button onclick="browseInstallPath()" class="browse-btn">
|
||||||
<i class="fas fa-folder-open"></i>
|
<i class="fas fa-folder-open"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -159,7 +163,8 @@
|
|||||||
<i class="fas fa-play-circle mr-2"></i>
|
<i class="fas fa-play-circle mr-2"></i>
|
||||||
<span data-i18n="play.ready">READY TO PLAY</span>
|
<span data-i18n="play.ready">READY TO PLAY</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="play-subtitle" data-i18n="play.subtitle">Launch Hytale and enter the adventure</p>
|
<p class="play-subtitle" data-i18n="play.subtitle">Launch Hytale and enter the
|
||||||
|
adventure</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="homePlayBtn" class="home-play-button" onclick="launch()">
|
<button id="homePlayBtn" class="home-play-button" onclick="launch()">
|
||||||
@@ -176,7 +181,8 @@
|
|||||||
<span data-i18n="play.latestNews">LATEST NEWS</span>
|
<span data-i18n="play.latestNews">LATEST NEWS</span>
|
||||||
</h2>
|
</h2>
|
||||||
<button class="view-all-btn" onclick="navigateToPage('news')">
|
<button class="view-all-btn" onclick="navigateToPage('news')">
|
||||||
<span data-i18n="play.viewAll">VIEW ALL</span> <i class="fas fa-arrow-right ml-1"></i>
|
<span data-i18n="play.viewAll">VIEW ALL</span> <i
|
||||||
|
class="fas fa-arrow-right ml-1"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="newsGrid" class="news-grid-horizontal"></div>
|
<div id="newsGrid" class="news-grid-horizontal"></div>
|
||||||
@@ -187,7 +193,8 @@
|
|||||||
<div class="mods-header">
|
<div class="mods-header">
|
||||||
<div class="mods-search-container">
|
<div class="mods-search-container">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
<input type="text" id="modsSearch" data-i18n-placeholder="mods.searchPlaceholder" class="mods-search" />
|
<input type="text" id="modsSearch" data-i18n-placeholder="mods.searchPlaceholder"
|
||||||
|
class="mods-search" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mods-actions">
|
<div class="mods-actions">
|
||||||
<button id="myModsBtn" class="mods-btn-primary">
|
<button id="myModsBtn" class="mods-btn-primary">
|
||||||
@@ -206,7 +213,8 @@
|
|||||||
<span data-i18n="mods.previous">PREVIOUS</span>
|
<span data-i18n="mods.previous">PREVIOUS</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="pagination-info">
|
<span class="pagination-info">
|
||||||
<span data-i18n="mods.page">Page</span> <span id="currentPage">1</span> <span data-i18n="mods.of">of</span> <span id="totalPages">1</span>
|
<span data-i18n="mods.page">Page</span> <span id="currentPage">1</span> <span
|
||||||
|
data-i18n="mods.of">of</span> <span id="totalPages">1</span>
|
||||||
</span>
|
</span>
|
||||||
<button id="nextPage" class="pagination-btn">
|
<button id="nextPage" class="pagination-btn">
|
||||||
<span data-i18n="mods.next">NEXT</span>
|
<span data-i18n="mods.next">NEXT</span>
|
||||||
@@ -287,12 +295,14 @@
|
|||||||
|
|
||||||
<div class="settings-option">
|
<div class="settings-option">
|
||||||
<div class="settings-input-group">
|
<div class="settings-input-group">
|
||||||
<label class="settings-input-label" data-i18n="settings.playerName">Player Name</label>
|
<label class="settings-input-label" data-i18n="settings.playerName">Player
|
||||||
|
Name</label>
|
||||||
<input type="text" id="settingsPlayerName" class="settings-input"
|
<input type="text" id="settingsPlayerName" class="settings-input"
|
||||||
data-i18n-placeholder="settings.playerNamePlaceholder" maxlength="16" />
|
data-i18n-placeholder="settings.playerNamePlaceholder" maxlength="16" />
|
||||||
<p class="settings-hint">
|
<p class="settings-hint">
|
||||||
<i class="fas fa-user"></i>
|
<i class="fas fa-user"></i>
|
||||||
<span data-i18n="settings.playerNameHint">This name will be used in-game (1-16 characters)</span>
|
<span data-i18n="settings.playerNameHint">This name will be used in-game
|
||||||
|
(1-16 characters)</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,8 +313,11 @@
|
|||||||
onclick="openGameLocation()">
|
onclick="openGameLocation()">
|
||||||
<i class="fas fa-folder-open"></i>
|
<i class="fas fa-folder-open"></i>
|
||||||
<div class="btn-content">
|
<div class="btn-content">
|
||||||
<div class="btn-title" data-i18n="settings.openGameLocation">Open Game Location</div>
|
<div class="btn-title" data-i18n="settings.openGameLocation">Open
|
||||||
<div class="btn-description" data-i18n="settings.openGameLocationDesc">Open the game installation folder</div>
|
Game Location</div>
|
||||||
|
<div class="btn-description"
|
||||||
|
data-i18n="settings.openGameLocationDesc">Open the game
|
||||||
|
installation folder</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,8 +329,10 @@
|
|||||||
onclick="repairGame()">
|
onclick="repairGame()">
|
||||||
<i class="fas fa-tools"></i>
|
<i class="fas fa-tools"></i>
|
||||||
<div class="btn-content">
|
<div class="btn-content">
|
||||||
<div class="btn-title" data-i18n="settings.repairGame">Repair Game</div>
|
<div class="btn-title" data-i18n="settings.repairGame">Repair Game
|
||||||
<div class="btn-description" data-i18n="settings.reinstallGame">Reinstall game files (preserves data)
|
</div>
|
||||||
|
<div class="btn-description" data-i18n="settings.reinstallGame">
|
||||||
|
Reinstall game files (preserves data)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -325,18 +340,25 @@
|
|||||||
|
|
||||||
|
|
||||||
<div class="settings-input-group">
|
<div class="settings-input-group">
|
||||||
<label class="settings-input-label" data-i18n="settings.gpuPreference">GPU Preference</label>
|
<label class="settings-input-label" data-i18n="settings.gpuPreference">GPU
|
||||||
|
Preference</label>
|
||||||
<div class="segmented-control">
|
<div class="segmented-control">
|
||||||
<input type="radio" id="gpu-auto" name="gpuPreference" value="auto" checked>
|
<input type="radio" id="gpu-auto" name="gpuPreference" value="auto"
|
||||||
|
checked>
|
||||||
<label for="gpu-auto" data-i18n="settings.gpuAuto">Auto</label>
|
<label for="gpu-auto" data-i18n="settings.gpuAuto">Auto</label>
|
||||||
<input type="radio" id="gpu-integrated" name="gpuPreference" value="integrated">
|
<input type="radio" id="gpu-integrated" name="gpuPreference"
|
||||||
<label for="gpu-integrated" data-i18n="settings.gpuIntegrated">Integrated</label>
|
value="integrated">
|
||||||
<input type="radio" id="gpu-dedicated" name="gpuPreference" value="dedicated">
|
<label for="gpu-integrated"
|
||||||
<label for="gpu-dedicated" data-i18n="settings.gpuDedicated">Dedicated</label>
|
data-i18n="settings.gpuIntegrated">Integrated</label>
|
||||||
|
<input type="radio" id="gpu-dedicated" name="gpuPreference"
|
||||||
|
value="dedicated">
|
||||||
|
<label for="gpu-dedicated"
|
||||||
|
data-i18n="settings.gpuDedicated">Dedicated</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="settings-hint">
|
<p class="settings-hint">
|
||||||
<i class="fas fa-info-circle"></i>
|
<i class="fas fa-info-circle"></i>
|
||||||
<span data-i18n="settings.gpuHint">Select your preferred GPU (Linux: affects DRI_PRIME)</span>
|
<span data-i18n="settings.gpuHint">Select your preferred GPU (Linux:
|
||||||
|
affects DRI_PRIME)</span>
|
||||||
</p>
|
</p>
|
||||||
<div id="gpu-detection-info" class="gpu-detection-info"></div>
|
<div id="gpu-detection-info" class="gpu-detection-info"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,7 +373,8 @@
|
|||||||
|
|
||||||
<div class="settings-option">
|
<div class="settings-option">
|
||||||
<div class="settings-input-group">
|
<div class="settings-input-group">
|
||||||
<label class="settings-input-label" data-i18n="settings.currentUUID">Current UUID</label>
|
<label class="settings-input-label" data-i18n="settings.currentUUID">Current
|
||||||
|
UUID</label>
|
||||||
<div class="uuid-display-container">
|
<div class="uuid-display-container">
|
||||||
<input type="text" id="currentUuid" class="settings-input uuid-input"
|
<input type="text" id="currentUuid" class="settings-input uuid-input"
|
||||||
readonly data-i18n-placeholder="settings.uuidPlaceholder" />
|
readonly data-i18n-placeholder="settings.uuidPlaceholder" />
|
||||||
@@ -365,7 +388,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="settings-hint">
|
<p class="settings-hint">
|
||||||
<i class="fas fa-info-circle"></i>
|
<i class="fas fa-info-circle"></i>
|
||||||
<span data-i18n="settings.uuidHint">Your unique player identifier for this username</span>
|
<span data-i18n="settings.uuidHint">Your unique player identifier for
|
||||||
|
this username</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -375,8 +399,10 @@
|
|||||||
<button id="manageUuidsBtn" class="settings-action-btn">
|
<button id="manageUuidsBtn" class="settings-action-btn">
|
||||||
<i class="fas fa-list"></i>
|
<i class="fas fa-list"></i>
|
||||||
<div class="btn-content">
|
<div class="btn-content">
|
||||||
<div class="btn-title" data-i18n="settings.manageUUIDs">Manage All UUIDs</div>
|
<div class="btn-title" data-i18n="settings.manageUUIDs">Manage All
|
||||||
<div class="btn-description" data-i18n="settings.manageUUIDsDesc">View and manage all player UUIDs</div>
|
UUIDs</div>
|
||||||
|
<div class="btn-description" data-i18n="settings.manageUUIDsDesc">
|
||||||
|
View and manage all player UUIDs</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -394,8 +420,11 @@
|
|||||||
<input type="checkbox" id="discordRPCCheck" checked />
|
<input type="checkbox" id="discordRPCCheck" checked />
|
||||||
<span class="checkmark"></span>
|
<span class="checkmark"></span>
|
||||||
<div class="checkbox-content">
|
<div class="checkbox-content">
|
||||||
<div class="checkbox-title" data-i18n="settings.enableRPC">Enable Discord Rich Presence</div>
|
<div class="checkbox-title" data-i18n="settings.enableRPC">Enable
|
||||||
<div class="checkbox-description" data-i18n="settings.discordDescription">Show your launcher activity on Discord
|
Discord Rich Presence</div>
|
||||||
|
<div class="checkbox-description"
|
||||||
|
data-i18n="settings.discordDescription">Show your launcher activity
|
||||||
|
on Discord
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -413,8 +442,10 @@
|
|||||||
<input type="checkbox" id="customJavaCheck" />
|
<input type="checkbox" id="customJavaCheck" />
|
||||||
<span class="checkmark"></span>
|
<span class="checkmark"></span>
|
||||||
<div class="checkbox-content">
|
<div class="checkbox-content">
|
||||||
<div class="checkbox-title" data-i18n="settings.useCustomJava">Use Custom Java Path</div>
|
<div class="checkbox-title" data-i18n="settings.useCustomJava">Use
|
||||||
<div class="checkbox-description" data-i18n="settings.javaDescription">Override the bundled Java runtime with
|
Custom Java Path</div>
|
||||||
|
<div class="checkbox-description" data-i18n="settings.javaDescription">
|
||||||
|
Override the bundled Java runtime with
|
||||||
your own installation</div>
|
your own installation</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -422,7 +453,8 @@
|
|||||||
|
|
||||||
<div id="customJavaOptions" class="custom-java-options" style="display: none;">
|
<div id="customJavaOptions" class="custom-java-options" style="display: none;">
|
||||||
<div class="settings-input-group">
|
<div class="settings-input-group">
|
||||||
<label class="settings-input-label" data-i18n="settings.javaPath">Java Executable Path</label>
|
<label class="settings-input-label" data-i18n="settings.javaPath">Java
|
||||||
|
Executable Path</label>
|
||||||
<div class="settings-input-with-button">
|
<div class="settings-input-with-button">
|
||||||
<input type="text" id="customJavaPath" class="settings-input"
|
<input type="text" id="customJavaPath" class="settings-input"
|
||||||
data-i18n-placeholder="settings.javaPathPlaceholder" readonly />
|
data-i18n-placeholder="settings.javaPathPlaceholder" readonly />
|
||||||
@@ -433,12 +465,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="settings-hint">
|
<p class="settings-hint">
|
||||||
<i class="fas fa-info-circle"></i>
|
<i class="fas fa-info-circle"></i>
|
||||||
<span data-i18n="settings.javaHint">Select the Java installation folder (supports Windows, Mac, Linux)</span>
|
<span data-i18n="settings.javaHint">Select the Java installation folder
|
||||||
|
(supports Windows, Mac, Linux)</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3 class="settings-section-title">
|
<h3 class="settings-section-title">
|
||||||
<i class="fas fa-language"></i>
|
<i class="fas fa-language"></i>
|
||||||
@@ -447,7 +480,8 @@
|
|||||||
|
|
||||||
<div class="settings-option">
|
<div class="settings-option">
|
||||||
<div class="settings-input-group">
|
<div class="settings-input-group">
|
||||||
<label class="settings-input-label" data-i18n="settings.selectLanguage">Select Language</label>
|
<label class="settings-input-label"
|
||||||
|
data-i18n="settings.selectLanguage">Select Language</label>
|
||||||
<select id="languageSelect" class="settings-input">
|
<select id="languageSelect" class="settings-input">
|
||||||
<!-- Options populated by i18n.js -->
|
<!-- Options populated by i18n.js -->
|
||||||
</select>
|
</select>
|
||||||
@@ -470,15 +504,18 @@
|
|||||||
<i class="fas fa-copy"></i> <span data-i18n="settings.logsCopy">Copy</span>
|
<i class="fas fa-copy"></i> <span data-i18n="settings.logsCopy">Copy</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="logs-action-btn" onclick="refreshLogs()">
|
<button class="logs-action-btn" onclick="refreshLogs()">
|
||||||
<i class="fas fa-sync-alt"></i> <span data-i18n="settings.logsRefresh">Refresh</span>
|
<i class="fas fa-sync-alt"></i> <span
|
||||||
|
data-i18n="settings.logsRefresh">Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="logs-action-btn" onclick="openLogsFolder()">
|
<button class="logs-action-btn" onclick="openLogsFolder()">
|
||||||
<i class="fas fa-folder-open"></i> <span data-i18n="settings.logsFolder">Open Folder</span>
|
<i class="fas fa-folder-open"></i> <span data-i18n="settings.logsFolder">Open
|
||||||
|
Folder</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="logsTerminal" class="logs-terminal">
|
<div id="logsTerminal" class="logs-terminal">
|
||||||
<div class="text-gray-500 text-center mt-10" data-i18n="settings.logsLoading">Loading logs...</div>
|
<div class="text-gray-500 text-center mt-10" data-i18n="settings.logsLoading">Loading
|
||||||
|
logs...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -547,10 +584,12 @@
|
|||||||
Choose a username to join the Players Chat
|
Choose a username to join the Players Chat
|
||||||
</p>
|
</p>
|
||||||
<div class="chat-username-input-group">
|
<div class="chat-username-input-group">
|
||||||
<label for="chatUsernameInput" class="chat-username-label" data-i18n="chat.username">Username</label>
|
<label for="chatUsernameInput" class="chat-username-label"
|
||||||
|
data-i18n="chat.username">Username</label>
|
||||||
<input type="text" id="chatUsernameInput" class="chat-username-input"
|
<input type="text" id="chatUsernameInput" class="chat-username-input"
|
||||||
data-i18n-placeholder="chat.usernamePlaceholder" maxlength="20" autocomplete="off" />
|
data-i18n-placeholder="chat.usernamePlaceholder" maxlength="20" autocomplete="off" />
|
||||||
<span class="chat-username-hint" data-i18n="chat.usernameHint">3-20 characters, letters, numbers, - and _ only</span>
|
<span class="chat-username-hint" data-i18n="chat.usernameHint">3-20 characters, letters, numbers, -
|
||||||
|
and _ only</span>
|
||||||
<span id="chatUsernameError" class="chat-username-error"></span>
|
<span id="chatUsernameError" class="chat-username-error"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -615,8 +654,7 @@
|
|||||||
<h3 class="uuid-section-title" data-i18n="uuid.setCustomUUID">Set Custom UUID</h3>
|
<h3 class="uuid-section-title" data-i18n="uuid.setCustomUUID">Set Custom UUID</h3>
|
||||||
<div class="uuid-custom-form">
|
<div class="uuid-custom-form">
|
||||||
<input type="text" id="customUuidInput" class="uuid-input"
|
<input type="text" id="customUuidInput" class="uuid-input"
|
||||||
data-i18n-placeholder="uuid.customPlaceholder"
|
data-i18n-placeholder="uuid.customPlaceholder" maxlength="36" />
|
||||||
maxlength="36" />
|
|
||||||
<button id="setCustomUuidBtn" class="uuid-set-btn">
|
<button id="setCustomUuidBtn" class="uuid-set-btn">
|
||||||
<i class="fas fa-check"></i>
|
<i class="fas fa-check"></i>
|
||||||
<span data-i18n="uuid.setUUID">Set UUID</span>
|
<span data-i18n="uuid.setUUID">Set UUID</span>
|
||||||
@@ -624,7 +662,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="uuid-custom-hint">
|
<p class="uuid-custom-hint">
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
<span data-i18n="uuid.warning">Warning: Setting a custom UUID will change your current player identity</span>
|
<span data-i18n="uuid.warning">Warning: Setting a custom UUID will change your current player
|
||||||
|
identity</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -648,8 +687,8 @@
|
|||||||
<!-- Populated by JS -->
|
<!-- Populated by JS -->
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-create-section">
|
<div class="profile-create-section">
|
||||||
<input type="text" id="newProfileName" data-i18n-placeholder="profiles.newProfilePlaceholder" class="profile-input"
|
<input type="text" id="newProfileName" data-i18n-placeholder="profiles.newProfilePlaceholder"
|
||||||
maxlength="20">
|
class="profile-input" maxlength="20">
|
||||||
<button class="profile-create-btn" onclick="createNewProfile()">
|
<button class="profile-create-btn" onclick="createNewProfile()">
|
||||||
<i class="fas fa-plus"></i> <span data-i18n="profiles.createProfile">Create Profile</span>
|
<i class="fas fa-plus"></i> <span data-i18n="profiles.createProfile">Create Profile</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -735,12 +774,15 @@
|
|||||||
|
|
||||||
<div class="color-preview">
|
<div class="color-preview">
|
||||||
<h4 data-i18n="chat.colorModal.preview">Preview:</h4>
|
<h4 data-i18n="chat.colorModal.preview">Preview:</h4>
|
||||||
<div id="colorPreview" class="preview-username" data-i18n="chat.colorModal.previewUsername">YourUsername</div>
|
<div id="colorPreview" class="preview-username" data-i18n="chat.colorModal.previewUsername">
|
||||||
|
YourUsername</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-color-modal-footer">
|
<div class="chat-color-modal-footer">
|
||||||
<button class="btn-secondary" onclick="closeChatColorModal()"><span data-i18n="common.cancel">Cancel</span></button>
|
<button class="btn-secondary" onclick="closeChatColorModal()"><span
|
||||||
<button class="btn-primary" onclick="applyChatColor()"><span data-i18n="chat.colorModal.apply">Apply Color</span></button>
|
data-i18n="common.cancel">Cancel</span></button>
|
||||||
|
<button class="btn-primary" onclick="applyChatColor()"><span data-i18n="chat.colorModal.apply">Apply
|
||||||
|
Color</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
const API_KEY = '$2a$10$bqk254NMZOWVTzLVJCcxEOmhcyUujKxA5xk.kQCN9q0KNYFJd5b32';
|
let API_KEY = null;
|
||||||
const CURSEFORGE_API = 'https://api.curseforge.com/v1';
|
const CURSEFORGE_API = 'https://api.curseforge.com/v1';
|
||||||
const HYTALE_GAME_ID = 70216;
|
const HYTALE_GAME_ID = 70216;
|
||||||
|
|
||||||
@@ -11,6 +11,15 @@ let modsPageSize = 20;
|
|||||||
let modsTotalPages = 1;
|
let modsTotalPages = 1;
|
||||||
|
|
||||||
export async function initModsManager() {
|
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();
|
setupModsEventListeners();
|
||||||
await loadInstalledMods();
|
await loadInstalledMods();
|
||||||
await loadBrowseMods();
|
await loadBrowseMods();
|
||||||
@@ -417,10 +426,10 @@ async function deleteMod(modId) {
|
|||||||
const mod = installedMods.find(m => m.id === modId);
|
const mod = installedMods.find(m => m.id === modId);
|
||||||
if (!mod) return;
|
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') :
|
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.`;
|
`Are you sure you want to delete "${mod.name}"? This action cannot be undone.`;
|
||||||
|
|
||||||
showConfirmModal(
|
showConfirmModal(
|
||||||
confirmMsg,
|
confirmMsg,
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -162,13 +162,18 @@ async function getModsPath(customInstallPath = null) {
|
|||||||
|
|
||||||
const modsPath = path.join(userDataPath, 'Mods');
|
const modsPath = path.join(userDataPath, 'Mods');
|
||||||
const disabledModsPath = path.join(userDataPath, 'DisabledMods');
|
const disabledModsPath = path.join(userDataPath, 'DisabledMods');
|
||||||
|
const profilesPath = path.join(userDataPath, 'Profiles');
|
||||||
|
|
||||||
if (!fs.existsSync(modsPath)) {
|
if (!fs.existsSync(modsPath)) {
|
||||||
|
// Ensure the Mods directory exists
|
||||||
fs.mkdirSync(modsPath, { recursive: true });
|
fs.mkdirSync(modsPath, { recursive: true });
|
||||||
}
|
}
|
||||||
if (!fs.existsSync(disabledModsPath)) {
|
if (!fs.existsSync(disabledModsPath)) {
|
||||||
fs.mkdirSync(disabledModsPath, { recursive: true });
|
fs.mkdirSync(disabledModsPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
if (!fs.existsSync(profilesPath)) {
|
||||||
|
fs.mkdirSync(profilesPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
return modsPath;
|
return modsPath;
|
||||||
} catch (error) {
|
} 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 = {
|
module.exports = {
|
||||||
getAppDir,
|
getAppDir,
|
||||||
getResolvedAppDir,
|
getResolvedAppDir,
|
||||||
@@ -191,5 +224,6 @@ module.exports = {
|
|||||||
findClientPath,
|
findClientPath,
|
||||||
findUserDataPath,
|
findUserDataPath,
|
||||||
findUserDataRecursive,
|
findUserDataRecursive,
|
||||||
getModsPath
|
getModsPath,
|
||||||
|
getProfilesDir
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,30 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { getModsPath } = require('../core/paths');
|
const { getModsPath, getProfilesDir } = require('../core/paths');
|
||||||
const { saveModsToConfig, loadModsFromConfig } = require('../core/config');
|
const { saveModsToConfig, loadModsFromConfig } = require('../core/config');
|
||||||
const profileManager = require('./profileManager');
|
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) {
|
function generateModId(filename) {
|
||||||
return crypto.createHash('md5').update(filename).digest('hex').substring(0, 8);
|
return crypto.createHash('md5').update(filename).digest('hex').substring(0, 8);
|
||||||
}
|
}
|
||||||
@@ -35,30 +55,33 @@ function getProfileMods() {
|
|||||||
|
|
||||||
async function loadInstalledMods(modsPath) {
|
async function loadInstalledMods(modsPath) {
|
||||||
try {
|
try {
|
||||||
|
// Sync first to ensure we detect any manually added mods and paths are correct
|
||||||
|
await syncModsForCurrentProfile();
|
||||||
|
|
||||||
const activeProfile = profileManager.getActiveProfile();
|
const activeProfile = profileManager.getActiveProfile();
|
||||||
if (!activeProfile) return [];
|
if (!activeProfile) return [];
|
||||||
|
|
||||||
const profileMods = activeProfile.mods || [];
|
const profileMods = activeProfile.mods || [];
|
||||||
const profileModFiles = new Set(profileMods.map(m => m.fileName));
|
|
||||||
|
// Use profile-specific paths
|
||||||
// We only return mods that are explicitly in the profile
|
const profileModsPath = getProfileModsPath(activeProfile.id);
|
||||||
// Check which ones are physically present (either in mods/ or DisabledMods/)
|
const profileDisabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods');
|
||||||
|
|
||||||
const physicalModsPath = modsPath; // .../mods
|
if (!fs.existsSync(profileModsPath)) fs.mkdirSync(profileModsPath, { recursive: true });
|
||||||
const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods');
|
if (!fs.existsSync(profileDisabledModsPath)) fs.mkdirSync(profileDisabledModsPath, { recursive: true });
|
||||||
|
|
||||||
const validMods = [];
|
const validMods = [];
|
||||||
|
|
||||||
for (const modConfig of profileMods) {
|
for (const modConfig of profileMods) {
|
||||||
// Check if file exists in either location
|
// Check if file exists in either location
|
||||||
const inEnabled = fs.existsSync(path.join(physicalModsPath, modConfig.fileName));
|
const inEnabled = fs.existsSync(path.join(profileModsPath, modConfig.fileName));
|
||||||
const inDisabled = fs.existsSync(path.join(disabledModsPath, modConfig.fileName));
|
const inDisabled = fs.existsSync(path.join(profileDisabledModsPath, modConfig.fileName));
|
||||||
|
|
||||||
if (inEnabled || inDisabled) {
|
if (inEnabled || inDisabled) {
|
||||||
validMods.push({
|
validMods.push({
|
||||||
...modConfig,
|
...modConfig,
|
||||||
// Set filePath based on physical location
|
// 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
|
enabled: modConfig.enabled !== false // Default true
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -82,7 +105,11 @@ async function loadInstalledMods(modsPath) {
|
|||||||
|
|
||||||
async function downloadMod(modInfo) {
|
async function downloadMod(modInfo) {
|
||||||
try {
|
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) {
|
if (!modInfo.downloadUrl && !modInfo.fileId) {
|
||||||
throw new Error('No download URL or file ID provided');
|
throw new Error('No download URL or file ID provided');
|
||||||
@@ -91,9 +118,9 @@ async function downloadMod(modInfo) {
|
|||||||
let downloadUrl = modInfo.downloadUrl;
|
let downloadUrl = modInfo.downloadUrl;
|
||||||
|
|
||||||
if (!downloadUrl && modInfo.fileId && modInfo.modId) {
|
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: {
|
headers: {
|
||||||
'x-api-key': modInfo.apiKey,
|
'x-api-key': modInfo.apiKey || API_KEY,
|
||||||
'Accept': 'application/json'
|
'Accept': 'application/json'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -119,35 +146,30 @@ async function downloadMod(modInfo) {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
writer.on('finish', () => {
|
writer.on('finish', () => {
|
||||||
// NEW: Update Active Profile instead of global config
|
// Update Active Profile
|
||||||
const activeProfile = profileManager.getActiveProfile();
|
const newMod = {
|
||||||
if (activeProfile) {
|
id: modInfo.id || generateModId(fileName),
|
||||||
const newMod = {
|
name: modInfo.name || extractModName(fileName),
|
||||||
id: modInfo.id || generateModId(fileName),
|
version: modInfo.version || '1.0.0',
|
||||||
name: modInfo.name || extractModName(fileName),
|
description: modInfo.summary || modInfo.description || 'Downloaded from CurseForge',
|
||||||
version: modInfo.version || '1.0.0',
|
author: modInfo.author || 'Unknown',
|
||||||
description: modInfo.summary || modInfo.description || 'Downloaded from CurseForge',
|
enabled: true,
|
||||||
author: modInfo.author || 'Unknown',
|
fileName: fileName,
|
||||||
enabled: true,
|
fileSize: fs.statSync(filePath).size,
|
||||||
fileName: fileName,
|
dateInstalled: new Date().toISOString(),
|
||||||
fileSize: fs.statSync(filePath).size,
|
curseForgeId: modInfo.modId,
|
||||||
dateInstalled: new Date().toISOString(),
|
curseForgeFileId: modInfo.fileId
|
||||||
curseForgeId: modInfo.modId,
|
};
|
||||||
curseForgeFileId: modInfo.fileId
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedMods = [...(activeProfile.mods || []), newMod];
|
const updatedMods = [...(activeProfile.mods || []), newMod];
|
||||||
profileManager.updateProfile(activeProfile.id, { mods: updatedMods });
|
profileManager.updateProfile(activeProfile.id, { mods: updatedMods });
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
success: true,
|
success: true,
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
modInfo: newMod
|
modInfo: newMod
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
reject(new Error('No active profile to save mod to'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
writer.on('error', reject);
|
writer.on('error', reject);
|
||||||
});
|
});
|
||||||
@@ -173,8 +195,11 @@ async function uninstallMod(modId, modsPath) {
|
|||||||
throw new Error('Mod not found in profile');
|
throw new Error('Mod not found in profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods');
|
// Use profile paths
|
||||||
const enabledPath = path.join(modsPath, mod.fileName);
|
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);
|
const disabledPath = path.join(disabledModsPath, mod.fileName);
|
||||||
|
|
||||||
let fileRemoved = false;
|
let fileRemoved = false;
|
||||||
@@ -226,31 +251,25 @@ async function toggleMod(modId, modsPath) {
|
|||||||
updatedMods[modIndex] = { ...mod, enabled: newEnabled };
|
updatedMods[modIndex] = { ...mod, enabled: newEnabled };
|
||||||
profileManager.updateProfile(activeProfile.id, { mods: updatedMods });
|
profileManager.updateProfile(activeProfile.id, { mods: updatedMods });
|
||||||
|
|
||||||
// Manually move the file to reflect the new state
|
// Move file between Profile/Mods and Profile/DisabledMods
|
||||||
const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods');
|
const profileModsPath = getProfileModsPath(activeProfile.id);
|
||||||
|
const disabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods');
|
||||||
|
|
||||||
if (!fs.existsSync(disabledModsPath)) fs.mkdirSync(disabledModsPath, { recursive: true });
|
if (!fs.existsSync(disabledModsPath)) fs.mkdirSync(disabledModsPath, { recursive: true });
|
||||||
|
|
||||||
const currentPath = mod.enabled ? path.join(modsPath, mod.fileName) : path.join(disabledModsPath, mod.fileName);
|
const currentPath = mod.enabled ? path.join(profileModsPath, mod.fileName) : path.join(disabledModsPath, mod.fileName);
|
||||||
|
const targetDir = newEnabled ? profileModsPath : disabledModsPath;
|
||||||
// Determine target paths
|
|
||||||
|
|
||||||
const targetDir = newEnabled ? modsPath : disabledModsPath;
|
|
||||||
const targetPath = path.join(targetDir, mod.fileName);
|
const targetPath = path.join(targetDir, mod.fileName);
|
||||||
|
|
||||||
if (fs.existsSync(currentPath)) {
|
if (fs.existsSync(currentPath)) {
|
||||||
fs.renameSync(currentPath, targetPath);
|
fs.renameSync(currentPath, targetPath);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: check if it's already in target?
|
// Fallback: check if it's already in target?
|
||||||
|
|
||||||
|
|
||||||
if (fs.existsSync(targetPath)) {
|
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`);
|
console.log(`[ModManager] Mod ${mod.fileName} is already in the correct state`);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Try finding it
|
// 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);
|
if (fs.existsSync(altPath)) fs.renameSync(altPath, targetPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,35 +292,166 @@ async function syncModsForCurrentProfile() {
|
|||||||
return;
|
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();
|
// 1. Resolve Paths
|
||||||
const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods');
|
// 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)) {
|
if (!fs.existsSync(profileDisabledModsPath)) {
|
||||||
fs.mkdirSync(disabledModsPath, { recursive: true });
|
fs.mkdirSync(profileDisabledModsPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all physical files from both folders
|
// 2. Symlink / Migration Logic
|
||||||
const enabledFiles = fs.existsSync(modsPath) ? fs.readdirSync(modsPath).filter(f => f.endsWith('.jar') || f.endsWith('.zip')) : [];
|
let needsLink = false;
|
||||||
const disabledFiles = fs.existsSync(disabledModsPath) ? fs.readdirSync(disabledModsPath).filter(f => f.endsWith('.jar') || f.endsWith('.zip')) : [];
|
|
||||||
|
|
||||||
|
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]);
|
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) {
|
for (const fileName of allFiles) {
|
||||||
const modConfig = profileMods.find(m => m.fileName === fileName);
|
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:
|
const currentPath = enabledFiles.includes(fileName) ? path.join(profileModsPath, fileName) : path.join(profileDisabledModsPath, fileName);
|
||||||
// If it should be enabled -> Move to mods/
|
const targetDir = shouldBeEnabled ? profileModsPath : profileDisabledModsPath;
|
||||||
// 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 targetPath = path.join(targetDir, fileName);
|
const targetPath = path.join(targetDir, fileName);
|
||||||
|
|
||||||
if (path.dirname(currentPath) !== targetDir) {
|
if (path.dirname(currentPath) !== targetDir) {
|
||||||
|
|||||||
9
main.js
9
main.js
@@ -1,5 +1,6 @@
|
|||||||
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
|
|
||||||
const path = require('path');
|
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 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, isGameInstalled, uninstallGame, repairGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched } = require('./backend/launcher');
|
||||||
const UpdateManager = require('./backend/updateManager');
|
const UpdateManager = require('./backend/updateManager');
|
||||||
@@ -28,7 +29,7 @@ let updateManager;
|
|||||||
let discordRPC = null;
|
let discordRPC = null;
|
||||||
|
|
||||||
// Discord Rich Presence setup
|
// Discord Rich Presence setup
|
||||||
const DISCORD_CLIENT_ID = '1462244937868513373';
|
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID;
|
||||||
|
|
||||||
function initDiscordRPC() {
|
function initDiscordRPC() {
|
||||||
try {
|
try {
|
||||||
@@ -690,6 +691,10 @@ ipcMain.handle('get-local-app-data', async () => {
|
|||||||
return process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
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 () => {
|
ipcMain.handle('get-user-id', async () => {
|
||||||
try {
|
try {
|
||||||
const { getOrCreatePlayerId } = require('./backend/launcher');
|
const { getOrCreatePlayerId } = require('./backend/launcher');
|
||||||
|
|||||||
251
package.json
251
package.json
@@ -1,103 +1,150 @@
|
|||||||
{
|
{
|
||||||
"name": "hytale-f2p-launcher",
|
"name": "hytale-f2p-launcher",
|
||||||
"version": "2.0.2b",
|
"version": "2.0.2b",
|
||||||
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
|
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
|
||||||
"homepage": "https://github.com/amiayweb/Hytale-F2P",
|
"homepage": "https://github.com/amiayweb/Hytale-F2P",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"dev": "electron . --dev",
|
"dev": "electron . --dev",
|
||||||
"build": "electron-builder",
|
"build": "electron-builder",
|
||||||
"build:win": "electron-builder --win",
|
"build:win": "electron-builder --win",
|
||||||
"build:linux": "electron-builder --linux",
|
"build:linux": "electron-builder --linux",
|
||||||
"build:mac": "electron-builder --mac",
|
"build:mac": "electron-builder --mac",
|
||||||
"build:all": "electron-builder --win --linux --mac"
|
"build:all": "electron-builder --win --linux --mac"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"hytale",
|
"hytale",
|
||||||
"launcher",
|
"launcher",
|
||||||
"game",
|
"game",
|
||||||
"client",
|
"client",
|
||||||
"cross-platform",
|
"cross-platform",
|
||||||
"electron",
|
"electron",
|
||||||
"auto-update",
|
"auto-update",
|
||||||
"mod-manager",
|
"mod-manager",
|
||||||
"chat"
|
"chat"
|
||||||
],
|
],
|
||||||
"maintainers": [
|
"maintainers": [
|
||||||
{
|
{
|
||||||
"name": "Terromur",
|
"name": "Terromur",
|
||||||
"url": "https://github.com/Terromur"
|
"url": "https://github.com/Terromur"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Fari Gading",
|
"name": "Fari Gading",
|
||||||
"email": "fazrigading@gmail.com",
|
"email": "fazrigading@gmail.com",
|
||||||
"url": "https://github.com/fazrigading"
|
"url": "https://github.com/fazrigading"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"author": {
|
"author": {
|
||||||
"name": "AMIAY",
|
"name": "AMIAY",
|
||||||
"email": "support@amiay.dev"
|
"email": "support@amiay.dev"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^40.0.0",
|
"electron": "^40.0.0",
|
||||||
"electron-builder": "^26.4.0"
|
"electron-builder": "^26.4.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.10",
|
"adm-zip": "^0.5.10",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
"tar": "^6.2.1",
|
"dotenv": "^17.2.3",
|
||||||
"uuid": "^9.0.1"
|
"tar": "^6.2.1",
|
||||||
},
|
"uuid": "^9.0.1"
|
||||||
"overrides": {
|
},
|
||||||
"tar": "$tar"
|
"overrides": {
|
||||||
},
|
"tar": "$tar"
|
||||||
"build": {
|
},
|
||||||
"appId": "com.hytalef2p.launcher",
|
"build": {
|
||||||
"productName": "Hytale F2P Launcher",
|
"appId": "com.hytalef2p.launcher",
|
||||||
"artifactName": "${name}_${version}_${arch}.${ext}",
|
"productName": "Hytale F2P Launcher",
|
||||||
"directories": {
|
"artifactName": "${name}_${version}_${arch}.${ext}",
|
||||||
"output": "dist"
|
"directories": {
|
||||||
},
|
"output": "dist"
|
||||||
"files": [
|
},
|
||||||
"main.js",
|
"files": [
|
||||||
"preload.js",
|
"main.js",
|
||||||
"backend/**/*",
|
"preload.js",
|
||||||
"GUI/**/*",
|
"backend/**/*",
|
||||||
"package.json"
|
"GUI/**/*",
|
||||||
],
|
"package.json",
|
||||||
"win": {
|
".env"
|
||||||
"target": [
|
],
|
||||||
{ "target": "nsis", "arch": ["x64", "arm64"] },
|
"win": {
|
||||||
{ "target": "portable", "arch": ["x64"] }
|
"target": [
|
||||||
],
|
{
|
||||||
"icon": "icon.ico"
|
"target": "nsis",
|
||||||
},
|
"arch": [
|
||||||
"linux": {
|
"x64",
|
||||||
"target": [
|
"arm64"
|
||||||
{ "target": "AppImage", "arch": ["x64", "arm64"] },
|
]
|
||||||
{ "target": "deb", "arch": ["x64", "arm64"] },
|
},
|
||||||
{ "target": "rpm", "arch": ["x64", "arm64"] },
|
{
|
||||||
{ "target": "pacman", "arch": ["x64", "arm64"] }
|
"target": "portable",
|
||||||
],
|
"arch": [
|
||||||
"icon": "build/icon.png",
|
"x64"
|
||||||
"category": "Game"
|
]
|
||||||
},
|
}
|
||||||
"mac": {
|
],
|
||||||
"target": [
|
"icon": "icon.ico"
|
||||||
{ "target": "dmg", "arch": ["universal"] },
|
},
|
||||||
{ "target": "zip", "arch": ["universal"] }
|
"linux": {
|
||||||
],
|
"target": [
|
||||||
"icon": "build/icon.icns",
|
{
|
||||||
"category": "public.app-category.games"
|
"target": "AppImage",
|
||||||
},
|
"arch": [
|
||||||
"nsis": {
|
"x64",
|
||||||
"oneClick": false,
|
"arm64"
|
||||||
"allowToChangeInstallationDirectory": true,
|
]
|
||||||
"createDesktopShortcut": true,
|
},
|
||||||
"createStartMenuShortcut": true
|
{
|
||||||
}
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
openGameLocation: () => ipcRenderer.invoke('open-game-location'),
|
openGameLocation: () => ipcRenderer.invoke('open-game-location'),
|
||||||
saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings),
|
saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings),
|
||||||
loadSettings: () => ipcRenderer.invoke('load-settings'),
|
loadSettings: () => ipcRenderer.invoke('load-settings'),
|
||||||
|
getEnvVar: (key) => ipcRenderer.invoke('get-env-var', key),
|
||||||
getLocalAppData: () => ipcRenderer.invoke('get-local-app-data'),
|
getLocalAppData: () => ipcRenderer.invoke('get-local-app-data'),
|
||||||
getModsPath: () => ipcRenderer.invoke('get-mods-path'),
|
getModsPath: () => ipcRenderer.invoke('get-mods-path'),
|
||||||
loadInstalledMods: (modsPath) => ipcRenderer.invoke('load-installed-mods', modsPath),
|
loadInstalledMods: (modsPath) => ipcRenderer.invoke('load-installed-mods', modsPath),
|
||||||
@@ -59,7 +60,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
onUpdatePopup: (callback) => {
|
onUpdatePopup: (callback) => {
|
||||||
ipcRenderer.on('show-update-popup', (event, data) => callback(data));
|
ipcRenderer.on('show-update-popup', (event, data) => callback(data));
|
||||||
},
|
},
|
||||||
|
|
||||||
getGpuInfo: () => ipcRenderer.invoke('get-gpu-info'),
|
getGpuInfo: () => ipcRenderer.invoke('get-gpu-info'),
|
||||||
saveGpuPreference: (gpuPreference) => ipcRenderer.invoke('save-gpu-preference', gpuPreference),
|
saveGpuPreference: (gpuPreference) => ipcRenderer.invoke('save-gpu-preference', gpuPreference),
|
||||||
loadGpuPreference: () => ipcRenderer.invoke('load-gpu-preference'),
|
loadGpuPreference: () => ipcRenderer.invoke('load-gpu-preference'),
|
||||||
|
|||||||
Reference in New Issue
Block a user