mirror of
https://git.sanhost.net/sanasol/hytale-f2p
synced 2026-02-26 06:51:47 -03:00
Release Stable Build v2.0.11 (#119)
* Add electron-updater auto-update support - Install electron-updater package - Configure GitHub releases publish settings - Create AppUpdater class with full update lifecycle - Integrate auto-update into main.js - Add comprehensive documentation (AUTO-UPDATES.md, TESTING-UPDATES.md) - Set up dev-app-update.yml for testing * Add cache clearing documentation for electron-updater - Introduced CLEAR-UPDATE-CACHE.md to guide users on clearing the electron-updater cache across macOS, Windows, and Linux. - Added programmatic method for cache clearing in JavaScript. - Enhanced update handling in main.js and preload.js to support new update events. - Updated GUI styles for download buttons and progress indicators in update.js and style.css. * Update auto-update UI and configuration - Fix version display (newVersion field) - Add download progress bar with real-time updates - Reorder buttons: Install & Restart (primary), Manually Download (secondary) - Update dev-app-update.yml to point to fork - Update package.json version to 2.0.2 * 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. * Adjust news card aspect ratio and add Play tab style Set a default aspect ratio for .news-card and add a specific style for the LATEST NEWS section in the Play tab to override the aspect ratio and use full height. * Add splash screen to launcher startup Introduced a new splash screen (splash.html) and updated main.js to display it on startup before loading the main window. The splash screen is shown for 2.5 seconds as a placeholder for future loading logic, improving user experience during application launch. * Display launcher version in UI Adds a version display element to the bottom right of the UI, fetching the version from package.json via a new IPC handler. Updates main.js, preload.js, and ui.js to support retrieving and displaying the version, and adds relevant styles in style.css. * 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 * 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 <fazrigading@gmail.com> Co-authored-by: Arnav Singh <hi.arnavsingh3@gmail.com> * Update publish config to point to chasem-dev fork * Fix Linux metadata files in workflow and improve error handling * Bump version to 2.0.5 * Bump version to 2.0.6 * Fix update popup showing for same version - add version comparison checks * Bump version to 2.0.7 * Fix SHA512 checksum mismatch handling - clear cache and retry automatically * Bump version to 2.0.8 * Bump version to 2.0.9 * Fix: Use explicit latest-linux.yml to prevent yml file collision The glob pattern latest*.yml was matching both latest-linux.yml AND latest.yml from the Linux build, causing the Windows latest.yml to be overwritten with incorrect checksums. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Bump version to 2.0.10 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Fix: Remove portable target to fix SHA512 checksum mismatch The portable and nsis targets both produced x64.exe files with the same name, causing one to overwrite the other. The latest.yml contained the checksum from one build while the actual file was from the other build. Removed portable target - nsis installer is sufficient. Bump version to 2.0.11 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Remove outdated documentation files related to auto-updates, build instructions, and testing updates. Update `dev-app-update.yml` and `package.json` to reflect the correct GitHub owner. This cleanup streamlines the project and ensures accurate configuration for future updates. * Add semantic versioning policy documentation - numerical versions only * Update package-lock.json to include new dependencies and versions, enhancing project stability and compatibility. * fixed imgur restriction for UK * fix: adds EGL env var to detect installed NVIDIA GPU * Update release.yml * patch v2.0.11-beta: fix env issue in GA release, warn Intel Mac users, add com templates. (#115) * fix: throw error for Intel Mac user * docs: first draft of issue and PR template * fix: env of curseforge API key and discord client ID * implemented late patch should be in #115 * Final patch for release.yml v2.0.11 --------- Co-authored-by: chasem-dev <myers.a.chase@gmail.com> Co-authored-by: AMIAY <letudiantenrap.collab@gmail.com> Co-authored-by: Rahul Sahani <110347707+Rahul-Sahani04@users.noreply.github.com> Co-authored-by: Arnav Singh <72737311+ArnavSingh77@users.noreply.github.com> Co-authored-by: Arnav Singh <hi.arnavsingh3@gmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
379
backend/appUpdater.js
Normal file
379
backend/appUpdater.js
Normal file
@@ -0,0 +1,379 @@
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
const { app } = require('electron');
|
||||
const logger = require('./logger');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
class AppUpdater {
|
||||
constructor(mainWindow) {
|
||||
this.mainWindow = mainWindow;
|
||||
this.autoUpdateAvailable = true; // Track if auto-update is possible
|
||||
this.updateAvailable = false; // Track if an update was detected
|
||||
this.updateVersion = null; // Store the available update version
|
||||
this.setupAutoUpdater();
|
||||
}
|
||||
|
||||
setupAutoUpdater() {
|
||||
// Enable dev mode for testing (reads dev-app-update.yml)
|
||||
// Only enable in development, not in production builds
|
||||
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
|
||||
autoUpdater.forceDevUpdateConfig = true;
|
||||
console.log('Dev update mode enabled - using dev-app-update.yml');
|
||||
}
|
||||
|
||||
// Configure logger for electron-updater
|
||||
// Create a compatible logger interface
|
||||
autoUpdater.logger = {
|
||||
info: (...args) => logger.info(...args),
|
||||
warn: (...args) => logger.warn(...args),
|
||||
error: (...args) => logger.error(...args),
|
||||
debug: (...args) => logger.log(...args)
|
||||
};
|
||||
|
||||
// Auto download updates
|
||||
autoUpdater.autoDownload = true;
|
||||
// Auto install on quit (after download)
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Event handlers
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
console.log('Checking for updates...');
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-checking');
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
console.log('Update available:', info.version);
|
||||
const currentVersion = app.getVersion();
|
||||
const newVersion = info.version;
|
||||
|
||||
// Only proceed if the new version is actually different from current
|
||||
if (newVersion === currentVersion) {
|
||||
console.log('Update version matches current version, ignoring update-available event');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateAvailable = true;
|
||||
this.updateVersion = newVersion;
|
||||
this.autoUpdateAvailable = true; // Reset flag when new update is available
|
||||
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-available', {
|
||||
version: newVersion,
|
||||
newVersion: newVersion,
|
||||
currentVersion: currentVersion,
|
||||
releaseName: info.releaseName,
|
||||
releaseNotes: info.releaseNotes
|
||||
});
|
||||
// Also send to the old popup handler for compatibility
|
||||
this.mainWindow.webContents.send('show-update-popup', {
|
||||
currentVersion: currentVersion,
|
||||
newVersion: newVersion,
|
||||
version: newVersion
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
console.log('Update not available. Current version is latest.');
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-not-available', {
|
||||
version: info.version
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
console.error('Error in auto-updater:', err);
|
||||
|
||||
// Check if this is a network error (not critical, don't show UI)
|
||||
const errorMessage = err.message?.toLowerCase() || '';
|
||||
const isNetworkError = errorMessage.includes('err_name_not_resolved') ||
|
||||
errorMessage.includes('network') ||
|
||||
errorMessage.includes('connection') ||
|
||||
errorMessage.includes('timeout') ||
|
||||
errorMessage.includes('enotfound');
|
||||
|
||||
if (isNetworkError) {
|
||||
console.warn('Network error in auto-updater - will retry later. Not showing error UI.');
|
||||
return; // Don't show error UI for network issues
|
||||
}
|
||||
|
||||
// Handle SHA512 checksum mismatch - this can happen during updates, just retry
|
||||
const isChecksumError = err.code === 'ERR_CHECKSUM_MISMATCH' ||
|
||||
errorMessage.includes('sha512') ||
|
||||
errorMessage.includes('checksum') ||
|
||||
errorMessage.includes('mismatch');
|
||||
|
||||
if (isChecksumError) {
|
||||
console.warn('SHA512 checksum mismatch detected - clearing cache and will retry automatically. This is normal during updates.');
|
||||
// Clear the update cache and let it re-download
|
||||
this.clearUpdateCache();
|
||||
|
||||
// Don't show error UI - just log and let it retry automatically on next check
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this is a critical error that prevents auto-update
|
||||
const isCriticalError = this.isCriticalUpdateError(err);
|
||||
|
||||
if (isCriticalError) {
|
||||
this.autoUpdateAvailable = false;
|
||||
console.warn('Auto-update failed. Manual download required.');
|
||||
}
|
||||
|
||||
// Handle missing metadata files (platform-specific builds)
|
||||
if (err.code === 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND') {
|
||||
const platform = process.platform === 'darwin' ? 'macOS' :
|
||||
process.platform === 'win32' ? 'Windows' : 'Linux';
|
||||
const missingFile = process.platform === 'darwin' ? 'latest-mac.yml' :
|
||||
process.platform === 'win32' ? 'latest.yml' : 'latest-linux.yml';
|
||||
console.warn(`${platform} update metadata file (${missingFile}) not found in release.`);
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-error', {
|
||||
message: `Update metadata file for ${platform} not found in release. Please download manually.`,
|
||||
code: err.code,
|
||||
requiresManualDownload: true,
|
||||
updateVersion: this.updateVersion,
|
||||
isMissingMetadata: true
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Linux-specific: Handle installation permission errors
|
||||
if (process.platform === 'linux') {
|
||||
const errorMessage = err.message?.toLowerCase() || '';
|
||||
const errorStack = err.stack?.toLowerCase() || '';
|
||||
const isInstallError = errorMessage.includes('pkexec') ||
|
||||
errorMessage.includes('gksudo') ||
|
||||
errorMessage.includes('kdesudo') ||
|
||||
errorMessage.includes('setuid root') ||
|
||||
errorMessage.includes('exited with code 127') ||
|
||||
errorStack.includes('pacmanupdater') ||
|
||||
errorStack.includes('doinstall') ||
|
||||
errorMessage.includes('installation failed');
|
||||
|
||||
if (isInstallError) {
|
||||
console.warn('Linux installation error: Package installation requires root privileges. Manual installation required.');
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-error', {
|
||||
message: 'Auto-installation requires root privileges. Please download and install the update manually.',
|
||||
code: err.code || 'ERR_LINUX_INSTALL_PERMISSION',
|
||||
isLinuxInstallError: true,
|
||||
requiresManualDownload: true,
|
||||
updateVersion: this.updateVersion
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// macOS-specific: Handle unsigned app errors gracefully
|
||||
if (process.platform === 'darwin' && err.code === 2) {
|
||||
console.warn('macOS update error: App may not be code-signed. Auto-update requires code signing.');
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-error', {
|
||||
message: 'Auto-update requires code signing. Please download manually from GitHub.',
|
||||
code: err.code,
|
||||
isMacSigningError: true,
|
||||
requiresManualDownload: true,
|
||||
updateVersion: this.updateVersion
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-error', {
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
requiresManualDownload: isCriticalError,
|
||||
updateVersion: this.updateVersion
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progressObj) => {
|
||||
const message = `Download speed: ${progressObj.bytesPerSecond} - Downloaded ${progressObj.percent}% (${progressObj.transferred}/${progressObj.total})`;
|
||||
console.log(message);
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-download-progress', {
|
||||
percent: progressObj.percent,
|
||||
bytesPerSecond: progressObj.bytesPerSecond,
|
||||
transferred: progressObj.transferred,
|
||||
total: progressObj.total
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
console.log('Update downloaded:', info.version);
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('update-downloaded', {
|
||||
version: info.version,
|
||||
releaseName: info.releaseName,
|
||||
releaseNotes: info.releaseNotes
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdatesAndNotify() {
|
||||
// Check for updates and notify if available
|
||||
autoUpdater.checkForUpdatesAndNotify().catch(err => {
|
||||
console.error('Failed to check for updates:', err);
|
||||
|
||||
// Network errors are not critical - just log and continue
|
||||
const errorMessage = err.message?.toLowerCase() || '';
|
||||
const isNetworkError = errorMessage.includes('err_name_not_resolved') ||
|
||||
errorMessage.includes('network') ||
|
||||
errorMessage.includes('connection') ||
|
||||
errorMessage.includes('timeout') ||
|
||||
errorMessage.includes('enotfound');
|
||||
|
||||
if (isNetworkError) {
|
||||
console.warn('Network error checking for updates - will retry later. This is not critical.');
|
||||
return; // Don't show error UI for network issues
|
||||
}
|
||||
|
||||
const isCritical = this.isCriticalUpdateError(err);
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed() && isCritical) {
|
||||
this.mainWindow.webContents.send('update-error', {
|
||||
message: err.message || 'Failed to check for updates',
|
||||
code: err.code,
|
||||
requiresManualDownload: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdates() {
|
||||
// Manual check for updates (returns promise)
|
||||
return autoUpdater.checkForUpdates().catch(err => {
|
||||
console.error('Failed to check for updates:', err);
|
||||
|
||||
// Network errors are not critical - just return no update available
|
||||
const errorMessage = err.message?.toLowerCase() || '';
|
||||
const isNetworkError = errorMessage.includes('err_name_not_resolved') ||
|
||||
errorMessage.includes('network') ||
|
||||
errorMessage.includes('connection') ||
|
||||
errorMessage.includes('timeout') ||
|
||||
errorMessage.includes('enotfound');
|
||||
|
||||
if (isNetworkError) {
|
||||
console.warn('Network error - update check unavailable');
|
||||
return { updateInfo: null }; // Return empty result for network errors
|
||||
}
|
||||
|
||||
const isCritical = this.isCriticalUpdateError(err);
|
||||
if (isCritical) {
|
||||
this.autoUpdateAvailable = false;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
quitAndInstall() {
|
||||
// Quit and install the update
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
}
|
||||
|
||||
getUpdateInfo() {
|
||||
return {
|
||||
currentVersion: app.getVersion(),
|
||||
updateAvailable: false
|
||||
};
|
||||
}
|
||||
|
||||
clearUpdateCache() {
|
||||
try {
|
||||
// Get the cache directory based on platform
|
||||
const cacheDir = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Caches', `${app.getName()}-updater`)
|
||||
: process.platform === 'win32'
|
||||
? path.join(os.homedir(), 'AppData', 'Local', `${app.getName()}-updater`)
|
||||
: path.join(os.homedir(), '.cache', `${app.getName()}-updater`);
|
||||
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
console.log('Update cache cleared successfully');
|
||||
} else {
|
||||
console.log('Update cache directory does not exist');
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.warn('Could not clear update cache:', cacheError.message);
|
||||
}
|
||||
}
|
||||
|
||||
isCriticalUpdateError(err) {
|
||||
// Check for errors that prevent auto-update
|
||||
const errorMessage = err.message?.toLowerCase() || '';
|
||||
const errorCode = err.code;
|
||||
|
||||
// Missing update metadata files (platform-specific)
|
||||
if (errorCode === 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND' ||
|
||||
errorMessage.includes('cannot find latest') ||
|
||||
errorMessage.includes('latest-linux.yml') ||
|
||||
errorMessage.includes('latest-mac.yml') ||
|
||||
errorMessage.includes('latest.yml')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// macOS code signing errors
|
||||
if (process.platform === 'darwin' && (errorCode === 2 || errorMessage.includes('shipit'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Download failures
|
||||
if (errorMessage.includes('download') && errorMessage.includes('fail')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Network errors that prevent download (but we handle these separately as non-critical)
|
||||
// Installation errors
|
||||
if (errorMessage.includes('install') && errorMessage.includes('fail')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Permission errors
|
||||
if (errorMessage.includes('permission') || errorMessage.includes('access denied')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Linux installation errors (pkexec, sudo issues)
|
||||
if (process.platform === 'linux' && (
|
||||
errorMessage.includes('pkexec') ||
|
||||
errorMessage.includes('setuid root') ||
|
||||
errorMessage.includes('exited with code 127') ||
|
||||
errorMessage.includes('gksudo') ||
|
||||
errorMessage.includes('kdesudo'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// File system errors (but not "not found" for metadata files - handled above)
|
||||
if (errorMessage.includes('enoent') || errorMessage.includes('cannot find')) {
|
||||
// Only if it's not about metadata files
|
||||
if (!errorMessage.includes('latest') && !errorMessage.includes('.yml')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Generic critical error codes (but not checksum errors - those are handled separately)
|
||||
if (errorCode && (errorCode >= 100 ||
|
||||
errorCode === 'ERR_UPDATER_INVALID_RELEASE_FEED' ||
|
||||
errorCode === 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND')) {
|
||||
// Don't treat checksum errors as critical - they're handled separately
|
||||
if (errorCode === 'ERR_CHECKSUM_MISMATCH') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AppUpdater;
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,11 @@ const { resolveJavaPath, detectSystemJava, downloadJRE, getJavaExec, getBundledJ
|
||||
async function downloadPWR(version = 'release', fileName = '4.pwr', progressCallback, cacheDir = CACHE_DIR) {
|
||||
const osName = getOS();
|
||||
const arch = getArch();
|
||||
|
||||
if (osName === 'darwin' && arch === 'amd64') {
|
||||
throw new Error('Hytale x86_64 Intel Mac Support has not been released yet. Please check back later.');
|
||||
}
|
||||
|
||||
const url = `https://game-patches.hytale.com/patches/${osName}/${arch}/${version}/0/${fileName}`;
|
||||
|
||||
const dest = path.join(cacheDir, fileName);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
function getOS() {
|
||||
if (process.platform === 'win32') return 'windows';
|
||||
@@ -217,12 +218,17 @@ function setupGpuEnvironment(gpuPreference) {
|
||||
const envVars = {};
|
||||
|
||||
if (finalPreference === 'dedicated') {
|
||||
envVars.DRI_PRIME = '1';
|
||||
if (detected.vendor === 'nvidia') {
|
||||
envVars.__NV_PRIME_RENDER_OFFLOAD = '1';
|
||||
envVars.__GLX_VENDOR_LIBRARY_NAME = 'nvidia';
|
||||
envVars.__GL_SHADER_DISK_CACHE = '1';
|
||||
envVars.__GL_SHADER_DISK_CACHE_PATH = '/tmp';
|
||||
const nvidiaEglFile = '/usr/share/glvnd/egl_vendor.d/10_nvidia.json';
|
||||
if (fs.existsSync(nvidiaEglFile)) {
|
||||
envVars.__EGL_VENDOR_LIBRARY_FILENAMES = nvidiaEglFile;
|
||||
} else {
|
||||
console.warn('NVIDIA EGL vendor library file not found, not setting __EGL_VENDOR_LIBRARY_FILENAMES');
|
||||
}
|
||||
} else {
|
||||
envVars.DRI_PRIME = '1';
|
||||
}
|
||||
console.log('GPU environment variables:', envVars);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user