Compare commits

..

No commits in common. "8d26db70dc14068133bc93c5c826742cc54e7ed9" and "b9b67b7824d1e11eb5552177c7dd782223c9eb87" have entirely different histories.

5 changed files with 871 additions and 198 deletions

View File

@ -6,6 +6,8 @@ const crypto = require('crypto')
const EventEmitter = require('events') const EventEmitter = require('events')
const fs = require('fs-extra') const fs = require('fs-extra')
const { LoggerUtil } = require('helios-core') const { LoggerUtil } = require('helios-core')
const { DistributionAPI } = require('helios-core/common')
const { Type } = require('helios-distribution-types')
const nodeDiskInfo = require('node-disk-info') const nodeDiskInfo = require('node-disk-info')
const StreamZip = require('node-stream-zip') const StreamZip = require('node-stream-zip')
const path = require('path') const path = require('path')
@ -14,6 +16,8 @@ const request = require('request')
const tar = require('tar-fs') const tar = require('tar-fs')
const zlib = require('zlib') const zlib = require('zlib')
const ConfigManager = require('./configmanager')
const { REMOTE_DISTRO_URL } = require('./distromanager')
const isDev = require('./isdev') const isDev = require('./isdev')
// Classes // Classes
@ -38,6 +42,86 @@ class Asset {
} }
} }
/** Class representing a mojang library. */
class Library extends Asset {
/**
* Converts the process.platform OS names to match mojang's OS names.
*/
static mojangFriendlyOS(){
const opSys = process.platform
if (opSys === 'darwin') {
return 'osx'
} else if (opSys === 'win32'){
return 'windows'
} else if (opSys === 'linux'){
return 'linux'
} else {
return 'unknown_os'
}
}
/**
* Checks whether or not a library is valid for download on a particular OS, following
* the rule format specified in the mojang version data index. If the allow property has
* an OS specified, then the library can ONLY be downloaded on that OS. If the disallow
* property has instead specified an OS, the library can be downloaded on any OS EXCLUDING
* the one specified.
*
* If the rules are undefined, the natives property will be checked for a matching entry
* for the current OS.
*
* @param {Array.<Object>} rules The Library's download rules.
* @param {Object} natives The Library's natives object.
* @returns {boolean} True if the Library follows the specified rules, otherwise false.
*/
static validateRules(rules, natives){
if(rules == null) {
if(natives == null) {
return true
} else {
return natives[Library.mojangFriendlyOS()] != null
}
}
for(let rule of rules){
const action = rule.action
const osProp = rule.os
if(action != null && osProp != null){
const osName = osProp.name
const osMoj = Library.mojangFriendlyOS()
if(action === 'allow'){
return osName === osMoj
} else if(action === 'disallow'){
return osName !== osMoj
}
}
}
return true
}
}
class DistroModule extends Asset {
/**
* Create a DistroModule. This is for processing,
* not equivalent to the module objects in the
* distro index.
*
* @param {any} id The id of the asset.
* @param {string} hash The hash value of the asset.
* @param {number} size The size in bytes of the asset.
* @param {string} from The url where the asset can be found.
* @param {string} to The absolute local file path of the asset.
* @param {string} type The the module type.
*/
constructor(id, hash, size, from, to, type){
super(id, hash, size, from, to)
this.type = type
}
}
/** /**
* Class representing a download tracker. This is used to store meta data * Class representing a download tracker. This is used to store meta data
* about a download queue, including the queue itself. * about a download queue, including the queue itself.
@ -1068,10 +1152,416 @@ class AssetGuard extends EventEmitter {
}) })
} }
/**
* Function which finalizes the forge installation process. This creates a 'version'
* instance for forge and saves its version.json file into that instance. If that
* instance already exists, the contents of the version.json file are read and returned
* in a promise.
*
* @param {Asset} asset The Asset object representing Forge.
* @param {string} commonPath The common path for shared game files.
* @returns {Promise.<Object>} A promise which resolves to the contents of forge's version.json.
*/
static _finalizeForgeAsset(asset, commonPath){
return new Promise((resolve, reject) => {
fs.readFile(asset.to, (err, data) => {
const zip = new AdmZip(data)
const zipEntries = zip.getEntries()
for(let i=0; i<zipEntries.length; i++){
if(zipEntries[i].entryName === 'version.json'){
const forgeVersion = JSON.parse(zip.readAsText(zipEntries[i]))
const versionPath = path.join(commonPath, 'versions', forgeVersion.id)
const versionFile = path.join(versionPath, forgeVersion.id + '.json')
if(!fs.existsSync(versionFile)){
fs.ensureDirSync(versionPath)
fs.writeFileSync(path.join(versionPath, forgeVersion.id + '.json'), zipEntries[i].getData())
resolve(forgeVersion)
} else {
//Read the saved file to allow for user modifications.
resolve(JSON.parse(fs.readFileSync(versionFile, 'utf-8')))
}
return
}
}
//We didn't find forge's version.json.
reject('Unable to finalize Forge processing, version.json not found! Has forge changed their format?')
})
})
}
// #endregion // #endregion
// #endregion // #endregion
// Validation Functions
// #region
/**
* Loads the version data for a given minecraft version.
*
* @param {string} version The game version for which to load the index data.
* @param {boolean} force Optional. If true, the version index will be downloaded even if it exists locally. Defaults to false.
* @returns {Promise.<Object>} Promise which resolves to the version data object.
*/
loadVersionData(version, force = false){
const self = this
return new Promise(async (resolve, reject) => {
const versionPath = path.join(self.commonPath, 'versions', version)
const versionFile = path.join(versionPath, version + '.json')
if(!fs.existsSync(versionFile) || force){
const url = await self._getVersionDataUrl(version)
//This download will never be tracked as it's essential and trivial.
AssetGuard.logger.info('Preparing download of ' + version + ' assets.')
fs.ensureDirSync(versionPath)
const stream = request(url).pipe(fs.createWriteStream(versionFile))
stream.on('finish', () => {
resolve(JSON.parse(fs.readFileSync(versionFile)))
})
} else {
resolve(JSON.parse(fs.readFileSync(versionFile)))
}
})
}
/**
* Parses Mojang's version manifest and retrieves the url of the version
* data index.
*
* @param {string} version The version to lookup.
* @returns {Promise.<string>} Promise which resolves to the url of the version data index.
* If the version could not be found, resolves to null.
*/
_getVersionDataUrl(version){
return new Promise((resolve, reject) => {
request('https://launchermeta.mojang.com/mc/game/version_manifest.json', (error, resp, body) => {
if(error){
reject(error)
} else {
const manifest = JSON.parse(body)
for(let v of manifest.versions){
if(v.id === version){
resolve(v.url)
}
}
resolve(null)
}
})
})
}
// Asset (Category=''') Validation Functions
// #region
/**
* Public asset validation function. This function will handle the validation of assets.
* It will parse the asset index specified in the version data, analyzing each
* asset entry. In this analysis it will check to see if the local file exists and is valid.
* If not, it will be added to the download queue for the 'assets' identifier.
*
* @param {Object} versionData The version data for the assets.
* @param {boolean} force Optional. If true, the asset index will be downloaded even if it exists locally. Defaults to false.
* @returns {Promise.<void>} An empty promise to indicate the async processing has completed.
*/
validateAssets(versionData, force = false){
const self = this
return new Promise((resolve, reject) => {
self._assetChainIndexData(versionData, force).then(() => {
resolve()
})
})
}
//Chain the asset tasks to provide full async. The below functions are private.
/**
* Private function used to chain the asset validation process. This function retrieves
* the index data.
* @param {Object} versionData
* @param {boolean} force
* @returns {Promise.<void>} An empty promise to indicate the async processing has completed.
*/
_assetChainIndexData(versionData, force = false){
const self = this
return new Promise((resolve, reject) => {
//Asset index constants.
const assetIndex = versionData.assetIndex
const name = assetIndex.id + '.json'
const indexPath = path.join(self.commonPath, 'assets', 'indexes')
const assetIndexLoc = path.join(indexPath, name)
let data = null
if(!fs.existsSync(assetIndexLoc) || force){
AssetGuard.logger.info('Downloading ' + versionData.id + ' asset index.')
fs.ensureDirSync(indexPath)
const stream = request(assetIndex.url).pipe(fs.createWriteStream(assetIndexLoc))
stream.on('finish', () => {
data = JSON.parse(fs.readFileSync(assetIndexLoc, 'utf-8'))
self._assetChainValidateAssets(versionData, data).then(() => {
resolve()
})
})
} else {
data = JSON.parse(fs.readFileSync(assetIndexLoc, 'utf-8'))
self._assetChainValidateAssets(versionData, data).then(() => {
resolve()
})
}
})
}
/**
* Private function used to chain the asset validation process. This function processes
* the assets and enqueues missing or invalid files.
* @param {Object} versionData
* @param {boolean} force
* @returns {Promise.<void>} An empty promise to indicate the async processing has completed.
*/
_assetChainValidateAssets(versionData, indexData){
const self = this
return new Promise((resolve, reject) => {
//Asset constants
const resourceURL = 'https://resources.download.minecraft.net/'
const localPath = path.join(self.commonPath, 'assets')
const objectPath = path.join(localPath, 'objects')
const assetDlQueue = []
let dlSize = 0
let acc = 0
const total = Object.keys(indexData.objects).length
//const objKeys = Object.keys(data.objects)
async.forEachOfLimit(indexData.objects, 10, (value, key, cb) => {
acc++
self.emit('progress', 'assets', acc, total)
const hash = value.hash
const assetName = path.join(hash.substring(0, 2), hash)
const urlName = hash.substring(0, 2) + '/' + hash
const ast = new Asset(key, hash, value.size, resourceURL + urlName, path.join(objectPath, assetName))
if(!AssetGuard._validateLocal(ast.to, 'sha1', ast.hash)){
dlSize += (ast.size*1)
assetDlQueue.push(ast)
}
cb()
}, (err) => {
self.assets = new DLTracker(assetDlQueue, dlSize)
resolve()
})
})
}
// #endregion
// Library (Category=''') Validation Functions
// #region
/**
* Public library validation function. This function will handle the validation of libraries.
* It will parse the version data, analyzing each library entry. In this analysis, it will
* check to see if the local file exists and is valid. If not, it will be added to the download
* queue for the 'libraries' identifier.
*
* @param {Object} versionData The version data for the assets.
* @returns {Promise.<void>} An empty promise to indicate the async processing has completed.
*/
validateLibraries(versionData){
const self = this
return new Promise((resolve, reject) => {
const libArr = versionData.libraries
const libPath = path.join(self.commonPath, 'libraries')
const libDlQueue = []
let dlSize = 0
//Check validity of each library. If the hashs don't match, download the library.
async.eachLimit(libArr, 5, (lib, cb) => {
if(Library.validateRules(lib.rules, lib.natives)){
let artifact = (lib.natives == null) ? lib.downloads.artifact : lib.downloads.classifiers[lib.natives[Library.mojangFriendlyOS()].replace('${arch}', process.arch.replace('x', ''))]
const libItm = new Library(lib.name, artifact.sha1, artifact.size, artifact.url, path.join(libPath, artifact.path))
if(!AssetGuard._validateLocal(libItm.to, 'sha1', libItm.hash)){
dlSize += (libItm.size*1)
libDlQueue.push(libItm)
}
}
cb()
}, (err) => {
self.libraries = new DLTracker(libDlQueue, dlSize)
resolve()
})
})
}
// #endregion
// Miscellaneous (Category=files) Validation Functions
// #region
/**
* Public miscellaneous mojang file validation function. These files will be enqueued under
* the 'files' identifier.
*
* @param {Object} versionData The version data for the assets.
* @returns {Promise.<void>} An empty promise to indicate the async processing has completed.
*/
validateMiscellaneous(versionData){
const self = this
return new Promise(async (resolve, reject) => {
await self.validateClient(versionData)
await self.validateLogConfig(versionData)
resolve()
})
}
/**
* Validate client file - artifact renamed from client.jar to '{version}'.jar.
*
* @param {Object} versionData The version data for the assets.
* @param {boolean} force Optional. If true, the asset index will be downloaded even if it exists locally. Defaults to false.
* @returns {Promise.<void>} An empty promise to indicate the async processing has completed.
*/
validateClient(versionData, force = false){
const self = this
return new Promise((resolve, reject) => {
const clientData = versionData.downloads.client
const version = versionData.id
const targetPath = path.join(self.commonPath, 'versions', version)
const targetFile = version + '.jar'
let client = new Asset(version + ' client', clientData.sha1, clientData.size, clientData.url, path.join(targetPath, targetFile))
if(!AssetGuard._validateLocal(client.to, 'sha1', client.hash) || force){
self.files.dlqueue.push(client)
self.files.dlsize += client.size*1
resolve()
} else {
resolve()
}
})
}
/**
* Validate log config.
*
* @param {Object} versionData The version data for the assets.
* @param {boolean} force Optional. If true, the asset index will be downloaded even if it exists locally. Defaults to false.
* @returns {Promise.<void>} An empty promise to indicate the async processing has completed.
*/
validateLogConfig(versionData){
const self = this
return new Promise((resolve, reject) => {
const client = versionData.logging.client
const file = client.file
const targetPath = path.join(self.commonPath, 'assets', 'log_configs')
let logConfig = new Asset(file.id, file.sha1, file.size, file.url, path.join(targetPath, file.id))
if(!AssetGuard._validateLocal(logConfig.to, 'sha1', logConfig.hash)){
self.files.dlqueue.push(logConfig)
self.files.dlsize += logConfig.size*1
resolve()
} else {
resolve()
}
})
}
// #endregion
// Distribution (Category=forge) Validation Functions
// #region
/**
* Validate the distribution.
*
* @param {Server} server The Server to validate.
* @returns {Promise.<Object>} A promise which resolves to the server distribution object.
*/
validateDistribution(server){
const self = this
return new Promise((resolve, reject) => {
self.forge = self._parseDistroModules(server.getModules(), server.getMinecraftVersion(), server.getID())
resolve(server)
})
}
_parseDistroModules(modules, version, servid){
let alist = []
let asize = 0
for(let ob of modules){
let obArtifact = ob.getArtifact()
let obPath = obArtifact.getPath()
let artifact = new DistroModule(ob.getIdentifier(), obArtifact.getHash(), obArtifact.getSize(), obArtifact.getURL(), obPath, ob.getType())
const validationPath = obPath.toLowerCase().endsWith('.pack.xz') ? obPath.substring(0, obPath.toLowerCase().lastIndexOf('.pack.xz')) : obPath
if(!AssetGuard._validateLocal(validationPath, 'MD5', artifact.hash)){
asize += artifact.size*1
alist.push(artifact)
if(validationPath !== obPath) this.extractQueue.push(obPath)
}
//Recursively process the submodules then combine the results.
if(ob.getSubModules() != null){
let dltrack = this._parseDistroModules(ob.getSubModules(), version, servid)
asize += dltrack.dlsize*1
alist = alist.concat(dltrack.dlqueue)
}
}
return new DLTracker(alist, asize)
}
/**
* Loads Forge's version.json data into memory for the specified server id.
*
* @param {string} server The Server to load Forge data for.
* @returns {Promise.<Object>} A promise which resolves to Forge's version.json data.
*/
loadForgeData(server){
const self = this
return new Promise(async (resolve, reject) => {
const modules = server.getModules()
for(let ob of modules){
const type = ob.getType()
if(type === Type.ForgeHosted || type === Type.Forge){
if(Util.isForgeGradle3(server.getMinecraftVersion(), ob.getVersion())){
// Read Manifest
for(let sub of ob.getSubModules()){
if(sub.getType() === Type.VersionManifest){
resolve(JSON.parse(fs.readFileSync(sub.getArtifact().getPath(), 'utf-8')))
return
}
}
reject('No forge version manifest found!')
return
} else {
let obArtifact = ob.getArtifact()
let obPath = obArtifact.getPath()
let asset = new DistroModule(ob.getIdentifier(), obArtifact.getHash(), obArtifact.getSize(), obArtifact.getURL(), obPath, type)
try {
let forgeData = await AssetGuard._finalizeForgeAsset(asset, self.commonPath)
resolve(forgeData)
} catch (err){
reject(err)
}
return
}
}
}
reject('No forge module found!')
})
}
_parseForgeLibraries(){
/* TODO
* Forge asset validations are already implemented. When there's nothing much
* to work on, implement forge downloads using forge's version.json. This is to
* have the code on standby if we ever need it (since it's half implemented already).
*/
}
// #endregion
// Java (Category=''') Validation (download) Functions // Java (Category=''') Validation (download) Functions
// #region // #region
@ -1154,6 +1644,43 @@ class AssetGuard extends EventEmitter {
} }
} }
// _enqueueMojangJRE(dir){
// return new Promise((resolve, reject) => {
// // Mojang does not host the JRE for linux.
// if(process.platform === 'linux'){
// resolve(false)
// }
// AssetGuard.loadMojangLauncherData().then(data => {
// if(data != null) {
// try {
// const mJRE = data[Library.mojangFriendlyOS()]['64'].jre
// const url = mJRE.url
// request.head(url, (err, resp, body) => {
// if(err){
// resolve(false)
// } else {
// const name = url.substring(url.lastIndexOf('/')+1)
// const fDir = path.join(dir, name)
// const jre = new Asset('jre' + mJRE.version, mJRE.sha1, resp.headers['content-length'], url, fDir)
// this.java = new DLTracker([jre], jre.size, a => {
// fs.readFile(a.to, (err, data) => {
// // Data buffer needs to be decompressed from lzma,
// // not really possible using node.js
// })
// })
// }
// })
// } catch (err){
// resolve(false)
// }
// }
// })
// })
// }
// #endregion // #endregion
@ -1313,11 +1840,63 @@ class AssetGuard extends EventEmitter {
}) })
} }
async validateEverything(serverid, dev = false){
try {
if(!ConfigManager.isLoaded()){
ConfigManager.load()
}
const api = new DistributionAPI(
ConfigManager.getLauncherDirectory(),
REMOTE_DISTRO_URL,
dev
)
const dI = await api.getDistributionLocalLoadOnly()
// TODO replace all
const server = dI.getServer(serverid)
// Validate Everything
await this.validateDistribution(server)
this.emit('validate', 'distribution')
const versionData = await this.loadVersionData(server.getMinecraftVersion())
this.emit('validate', 'version')
await this.validateAssets(versionData)
this.emit('validate', 'assets')
await this.validateLibraries(versionData)
this.emit('validate', 'libraries')
await this.validateMiscellaneous(versionData)
this.emit('validate', 'files')
await this.processDlQueues()
//this.emit('complete', 'download')
const forgeData = await this.loadForgeData(server)
return {
versionData,
forgeData
}
} catch (err){
return {
versionData: null,
forgeData: null,
error: err
}
}
}
// #endregion // #endregion
} }
module.exports = { module.exports = {
Util,
AssetGuard, AssetGuard,
JavaGuard JavaGuard,
Asset,
Library
} }

View File

@ -6,8 +6,6 @@ exports.REMOTE_DISTRO_URL = 'http://mc.westeroscraft.com/WesterosCraftLauncher/d
const api = new DistributionAPI( const api = new DistributionAPI(
ConfigManager.getLauncherDirectory(), ConfigManager.getLauncherDirectory(),
null, // Injected forcefully by the preloader.
null, // Injected forcefully by the preloader.
exports.REMOTE_DISTRO_URL, exports.REMOTE_DISTRO_URL,
false false
) )

View File

@ -17,11 +17,6 @@ logger.info('Loading..')
// Load ConfigManager // Load ConfigManager
ConfigManager.load() ConfigManager.load()
// Yuck!
// TODO Fix this
DistroAPI['commonDir'] = ConfigManager.getCommonDirectory()
DistroAPI['instanceDir'] = ConfigManager.getInstanceDirectory()
// Load Strings // Load Strings
LangLoader.loadLanguage('en_US') LangLoader.loadLanguage('en_US')

View File

@ -7,6 +7,7 @@ const { getMojangOS, isLibraryCompatible, mcVersionAtLeast } = require('helios-
const { Type } = require('helios-distribution-types') const { Type } = require('helios-distribution-types')
const os = require('os') const os = require('os')
const path = require('path') const path = require('path')
const { URL } = require('url')
const ConfigManager = require('./configmanager') const ConfigManager = require('./configmanager')
@ -15,7 +16,7 @@ const logger = LoggerUtil.getLogger('ProcessBuilder')
class ProcessBuilder { class ProcessBuilder {
constructor(distroServer, versionData, forgeData, authUser, launcherVersion){ constructor(distroServer, versionData, forgeData, authUser, launcherVersion){
this.gameDir = path.join(ConfigManager.getInstanceDirectory(), distroServer.rawServer.id) this.gameDir = path.join(ConfigManager.getInstanceDirectory(), distroServer.getID())
this.commonDir = ConfigManager.getCommonDirectory() this.commonDir = ConfigManager.getCommonDirectory()
this.server = distroServer this.server = distroServer
this.versionData = versionData this.versionData = versionData
@ -40,10 +41,10 @@ class ProcessBuilder {
process.throwDeprecation = true process.throwDeprecation = true
this.setupLiteLoader() this.setupLiteLoader()
logger.info('Using liteloader:', this.usingLiteLoader) logger.info('Using liteloader:', this.usingLiteLoader)
const modObj = this.resolveModConfiguration(ConfigManager.getModConfiguration(this.server.rawServer.id).mods, this.server.modules) const modObj = this.resolveModConfiguration(ConfigManager.getModConfiguration(this.server.getID()).mods, this.server.getModules())
// Mod list below 1.13 // Mod list below 1.13
if(!mcVersionAtLeast('1.13', this.server.rawServer.minecraftVersion)){ if(!mcVersionAtLeast('1.13', this.server.getMinecraftVersion())){
this.constructJSONModList('forge', modObj.fMods, true) this.constructJSONModList('forge', modObj.fMods, true)
if(this.usingLiteLoader){ if(this.usingLiteLoader){
this.constructJSONModList('liteloader', modObj.lMods, true) this.constructJSONModList('liteloader', modObj.lMods, true)
@ -53,14 +54,14 @@ class ProcessBuilder {
const uberModArr = modObj.fMods.concat(modObj.lMods) const uberModArr = modObj.fMods.concat(modObj.lMods)
let args = this.constructJVMArguments(uberModArr, tempNativePath) let args = this.constructJVMArguments(uberModArr, tempNativePath)
if(mcVersionAtLeast('1.13', this.server.rawServer.minecraftVersion)){ if(mcVersionAtLeast('1.13', this.server.getMinecraftVersion())){
//args = args.concat(this.constructModArguments(modObj.fMods)) //args = args.concat(this.constructModArguments(modObj.fMods))
args = args.concat(this.constructModList(modObj.fMods)) args = args.concat(this.constructModList(modObj.fMods))
} }
logger.info('Launch Arguments:', args) logger.info('Launch Arguments:', args)
const child = child_process.spawn(ConfigManager.getJavaExecutable(this.server.rawServer.id), args, { const child = child_process.spawn(ConfigManager.getJavaExecutable(this.server.getID()), args, {
cwd: this.gameDir, cwd: this.gameDir,
detached: ConfigManager.getLaunchDetached() detached: ConfigManager.getLaunchDetached()
}) })
@ -136,15 +137,15 @@ class ProcessBuilder {
if(!ll.getRequired().value){ if(!ll.getRequired().value){
const modCfg = ConfigManager.getModConfiguration(this.server.rawServer.id).mods const modCfg = ConfigManager.getModConfiguration(this.server.rawServer.id).mods
if(ProcessBuilder.isModEnabled(modCfg[ll.getVersionlessMavenIdentifier()], ll.getRequired())){ if(ProcessBuilder.isModEnabled(modCfg[ll.getVersionlessMavenIdentifier()], ll.getRequired())){
if(fs.existsSync(ll.getPath())){ if(fs.existsSync(ll.localPath)){
this.usingLiteLoader = true this.usingLiteLoader = true
this.llPath = ll.getPath() this.llPath = ll.localPath
} }
} }
} else { } else {
if(fs.existsSync(ll.getPath())){ if(fs.existsSync(ll.localPath)){
this.usingLiteLoader = true this.usingLiteLoader = true
this.llPath = ll.getPath() this.llPath = ll.localPath
} }
} }
} }
@ -306,11 +307,14 @@ class ProcessBuilder {
} }
_processAutoConnectArg(args){ _processAutoConnectArg(args){
if(ConfigManager.getAutoConnect() && this.server.rawServer.autoconnect){ if(ConfigManager.getAutoConnect() && this.server.isAutoConnect()){
const serverURL = new URL('my://' + this.server.getAddress())
args.push('--server') args.push('--server')
args.push(this.server.hostname) args.push(serverURL.hostname)
args.push('--port') if(serverURL.port){
args.push(this.server.port) args.push('--port')
args.push(serverURL.port)
}
} }
} }
@ -322,7 +326,7 @@ class ProcessBuilder {
* @returns {Array.<string>} An array containing the full JVM arguments for this process. * @returns {Array.<string>} An array containing the full JVM arguments for this process.
*/ */
constructJVMArguments(mods, tempNativePath){ constructJVMArguments(mods, tempNativePath){
if(mcVersionAtLeast('1.13', this.server.rawServer.minecraftVersion)){ if(mcVersionAtLeast('1.13', this.server.getMinecraftVersion())){
return this._constructJVMArguments113(mods, tempNativePath) return this._constructJVMArguments113(mods, tempNativePath)
} else { } else {
return this._constructJVMArguments112(mods, tempNativePath) return this._constructJVMArguments112(mods, tempNativePath)
@ -350,9 +354,9 @@ class ProcessBuilder {
args.push('-Xdock:name=HeliosLauncher') args.push('-Xdock:name=HeliosLauncher')
args.push('-Xdock:icon=' + path.join(__dirname, '..', 'images', 'minecraft.icns')) args.push('-Xdock:icon=' + path.join(__dirname, '..', 'images', 'minecraft.icns'))
} }
args.push('-Xmx' + ConfigManager.getMaxRAM(this.server.rawServer.id)) args.push('-Xmx' + ConfigManager.getMaxRAM(this.server.getID()))
args.push('-Xms' + ConfigManager.getMinRAM(this.server.rawServer.id)) args.push('-Xms' + ConfigManager.getMinRAM(this.server.getID()))
args = args.concat(ConfigManager.getJVMOptions(this.server.rawServer.id)) args = args.concat(ConfigManager.getJVMOptions(this.server.getID()))
args.push('-Djava.library.path=' + tempNativePath) args.push('-Djava.library.path=' + tempNativePath)
// Main Java Class // Main Java Class
@ -401,9 +405,9 @@ class ProcessBuilder {
args.push('-Xdock:name=HeliosLauncher') args.push('-Xdock:name=HeliosLauncher')
args.push('-Xdock:icon=' + path.join(__dirname, '..', 'images', 'minecraft.icns')) args.push('-Xdock:icon=' + path.join(__dirname, '..', 'images', 'minecraft.icns'))
} }
args.push('-Xmx' + ConfigManager.getMaxRAM(this.server.rawServer.id)) args.push('-Xmx' + ConfigManager.getMaxRAM(this.server.getID()))
args.push('-Xms' + ConfigManager.getMinRAM(this.server.rawServer.id)) args.push('-Xms' + ConfigManager.getMinRAM(this.server.getID()))
args = args.concat(ConfigManager.getJVMOptions(this.server.rawServer.id)) args = args.concat(ConfigManager.getJVMOptions(this.server.getID()))
// Main Java Class // Main Java Class
args.push(this.forgeData.mainClass) args.push(this.forgeData.mainClass)
@ -467,7 +471,7 @@ class ProcessBuilder {
break break
case 'version_name': case 'version_name':
//val = versionData.id //val = versionData.id
val = this.server.rawServer.id val = this.server.getID()
break break
case 'game_directory': case 'game_directory':
val = this.gameDir val = this.gameDir
@ -565,7 +569,7 @@ class ProcessBuilder {
break break
case 'version_name': case 'version_name':
//val = versionData.id //val = versionData.id
val = this.server.rawServer.id val = this.server.getID()
break break
case 'game_directory': case 'game_directory':
val = this.gameDir val = this.gameDir
@ -664,7 +668,7 @@ class ProcessBuilder {
classpathArg(mods, tempNativePath){ classpathArg(mods, tempNativePath){
let cpArgs = [] let cpArgs = []
if(!mcVersionAtLeast('1.17', this.server.rawServer.minecraftVersion)) { if(!mcVersionAtLeast('1.17', this.server.getMinecraftVersion())) {
// Add the version.jar to the classpath. // Add the version.jar to the classpath.
// Must not be added to the classpath for Forge 1.17+. // Must not be added to the classpath for Forge 1.17+.
const version = this.versionData.id const version = this.versionData.id
@ -822,15 +826,15 @@ class ProcessBuilder {
* @returns {{[id: string]: string}} An object containing the paths of each library this server requires. * @returns {{[id: string]: string}} An object containing the paths of each library this server requires.
*/ */
_resolveServerLibraries(mods){ _resolveServerLibraries(mods){
const mdls = this.server.modules const mdls = this.server.getModules()
let libs = {} let libs = {}
// Locate Forge/Libraries // Locate Forge/Libraries
for(let mdl of mdls){ for(let mdl of mdls){
const type = mdl.rawModule.type const type = mdl.getType()
if(type === Type.ForgeHosted || type === Type.Library){ if(type === Type.ForgeHosted || type === Type.Library){
libs[mdl.getVersionlessMavenIdentifier()] = mdl.getPath() libs[mdl.getVersionlessID()] = mdl.getArtifact().getPath()
if(mdl.subModules.length > 0){ if(mdl.hasSubModules()){
const res = this._resolveModuleLibraries(mdl) const res = this._resolveModuleLibraries(mdl)
if(res.length > 0){ if(res.length > 0){
libs = {...libs, ...res} libs = {...libs, ...res}
@ -859,20 +863,20 @@ class ProcessBuilder {
* @returns {Array.<string>} An array containing the paths of each library this module requires. * @returns {Array.<string>} An array containing the paths of each library this module requires.
*/ */
_resolveModuleLibraries(mdl){ _resolveModuleLibraries(mdl){
if(!mdl.subModules.length > 0){ if(!mdl.hasSubModules()){
return [] return []
} }
let libs = [] let libs = []
for(let sm of mdl.subModules){ for(let sm of mdl.getSubModules()){
if(sm.rawModule.type === Type.Library){ if(sm.getType() === Type.Library){
if(sm.rawModule.classpath ?? true) { if(sm.getClasspath()) {
libs.push(sm.getPath()) libs.push(sm.getArtifact().getPath())
} }
} }
// If this module has submodules, we need to resolve the libraries for those. // If this module has submodules, we need to resolve the libraries for those.
// To avoid unnecessary recursive calls, base case is checked here. // To avoid unnecessary recursive calls, base case is checked here.
if(mdl.subModules.length > 0){ if(mdl.hasSubModules()){
const res = this._resolveModuleLibraries(sm) const res = this._resolveModuleLibraries(sm)
if(res.length > 0){ if(res.length > 0){
libs = libs.concat(res) libs = libs.concat(res)

View File

@ -5,24 +5,13 @@
const cp = require('child_process') const cp = require('child_process')
const crypto = require('crypto') const crypto = require('crypto')
const { URL } = require('url') const { URL } = require('url')
const { const { MojangRestAPI, getServerStatus } = require('helios-core/mojang')
MojangRestAPI,
getServerStatus
} = require('helios-core/mojang')
const {
RestResponseStatus,
isDisplayableError,
mcVersionAtLeast
} = require('helios-core/common')
const {
FullRepair,
DistributionIndexProcessor,
MojangIndexProcessor
} = require('helios-core/dl')
// Internal Requirements // Internal Requirements
const DiscordWrapper = require('./assets/js/discordwrapper') const DiscordWrapper = require('./assets/js/discordwrapper')
const ProcessBuilder = require('./assets/js/processbuilder') const ProcessBuilder = require('./assets/js/processbuilder')
const { RestResponseStatus, isDisplayableError, mcVersionAtLeast } = require('helios-core/common')
const { stdout } = require('process')
// Launch Elements // Launch Elements
const launch_content = document.getElementById('launch_content') const launch_content = document.getElementById('launch_content')
@ -64,22 +53,26 @@ function setLaunchDetails(details){
/** /**
* Set the value of the loading progress bar and display that value. * Set the value of the loading progress bar and display that value.
* *
* @param {number} percent Percentage (0-100) * @param {number} value The progress value.
* @param {number} max The total size.
* @param {number|string} percent Optional. The percentage to display on the progress label.
*/ */
function setLaunchPercentage(percent){ function setLaunchPercentage(value, max, percent = ((value/max)*100)){
launch_progress.setAttribute('max', 100) launch_progress.setAttribute('max', max)
launch_progress.setAttribute('value', percent) launch_progress.setAttribute('value', value)
launch_progress_label.innerHTML = percent + '%' launch_progress_label.innerHTML = percent + '%'
} }
/** /**
* Set the value of the OS progress bar and display that on the UI. * Set the value of the OS progress bar and display that on the UI.
* *
* @param {number} percent Percentage (0-100) * @param {number} value The progress value.
* @param {number} max The total download size.
* @param {number|string} percent Optional. The percentage to display on the progress label.
*/ */
function setDownloadPercentage(percent){ function setDownloadPercentage(value, max, percent = ((value/max)*100)){
remote.getCurrentWindow().setProgressBar(percent/100) remote.getCurrentWindow().setProgressBar(value/max)
setLaunchPercentage(percent) setLaunchPercentage(value, max, percent)
} }
/** /**
@ -105,10 +98,10 @@ document.getElementById('launch_button').addEventListener('click', async (e) =>
setLaunchPercentage(0, 100) setLaunchPercentage(0, 100)
const jg = new JavaGuard(mcVersion) const jg = new JavaGuard(mcVersion)
jg._validateJavaBinary(jExe).then(async v => { jg._validateJavaBinary(jExe).then((v) => {
loggerLanding.info('Java version meta', v) loggerLanding.info('Java version meta', v)
if(v.valid){ if(v.valid){
await dlAsync() dlAsync()
} else { } else {
asyncSystemScan(mcVersion) asyncSystemScan(mcVersion)
} }
@ -376,7 +369,7 @@ function asyncSystemScan(mcVersion, launchAfter = true){
await populateJavaExecDetails(settingsJavaExecVal.value) await populateJavaExecDetails(settingsJavaExecVal.value)
if(launchAfter){ if(launchAfter){
await dlAsync() dlAsync()
} }
sysAEx.disconnect() sysAEx.disconnect()
} }
@ -452,7 +445,7 @@ function asyncSystemScan(mcVersion, launchAfter = true){
setLaunchDetails('Java Installed!') setLaunchDetails('Java Installed!')
if(launchAfter){ if(launchAfter){
await dlAsync() dlAsync()
} }
sysAEx.disconnect() sysAEx.disconnect()
@ -480,28 +473,18 @@ const GAME_JOINED_REGEX = /\[.+\]: Sound engine started/
const GAME_LAUNCH_REGEX = /^\[.+\]: (?:MinecraftForge .+ Initialized|ModLauncher .+ starting: .+)$/ const GAME_LAUNCH_REGEX = /^\[.+\]: (?:MinecraftForge .+ Initialized|ModLauncher .+ starting: .+)$/
const MIN_LINGER = 5000 const MIN_LINGER = 5000
async function dlAsync(login = true) { let aEx
let serv
let versionData
let forgeData
let progressListener
function dlAsync(login = true){
// Login parameter is temporary for debug purposes. Allows testing the validation/downloads without // Login parameter is temporary for debug purposes. Allows testing the validation/downloads without
// launching the game. // launching the game.
const loggerLaunchSuite = LoggerUtil.getLogger('LaunchSuite')
setLaunchDetails('Loading server information..')
let distro
try {
distro = await DistroAPI.refreshDistributionOrFallback()
onDistroRefresh(distro)
} catch(err) {
loggerLaunchSuite.error('Unable to refresh distribution index.', err)
showLaunchFailure('Fatal Error', 'Could not load a copy of the distribution index. See the console (CTRL + Shift + i) for more details.')
return
}
const serv = distro.getServerById(ConfigManager.getSelectedServer())
if(login) { if(login) {
if(ConfigManager.getSelectedAccount() == null){ if(ConfigManager.getSelectedAccount() == null){
loggerLanding.error('You must be logged into an account.') loggerLanding.error('You must be logged into an account.')
@ -513,148 +496,262 @@ async function dlAsync(login = true) {
toggleLaunchArea(true) toggleLaunchArea(true)
setLaunchPercentage(0, 100) setLaunchPercentage(0, 100)
const fullRepairModule = new FullRepair( const loggerLaunchSuite = LoggerUtil.getLogger('LaunchSuite')
const forkEnv = JSON.parse(JSON.stringify(process.env))
forkEnv.CONFIG_DIRECT_PATH = ConfigManager.getLauncherDirectory()
// Start AssetExec to run validations and downloads in a forked process.
aEx = cp.fork(path.join(__dirname, 'assets', 'js', 'assetexec.js'), [
'AssetGuard',
ConfigManager.getCommonDirectory(), ConfigManager.getCommonDirectory(),
ConfigManager.getInstanceDirectory(), ConfigManager.getJavaExecutable(ConfigManager.getSelectedServer())
ConfigManager.getLauncherDirectory(), ], {
ConfigManager.getSelectedServer(), env: forkEnv,
DistroAPI.isDevMode() stdio: 'pipe'
) })
// Stdout
fullRepairModule.spawnReceiver() aEx.stdio[1].setEncoding('utf8')
aEx.stdio[1].on('data', (data) => {
fullRepairModule.childProcess.on('error', (err) => { console.log(`\x1b[32m[AEx]\x1b[0m ${data}`)
})
// Stderr
aEx.stdio[2].setEncoding('utf8')
aEx.stdio[2].on('data', (data) => {
console.log(`\x1b[31m[AEx]\x1b[0m ${data}`)
})
aEx.on('error', (err) => {
loggerLaunchSuite.error('Error during launch', err) loggerLaunchSuite.error('Error during launch', err)
showLaunchFailure('Error During Launch', err.message || 'See console (CTRL + Shift + i) for more details.') showLaunchFailure('Error During Launch', err.message || 'See console (CTRL + Shift + i) for more details.')
}) })
fullRepairModule.childProcess.on('close', (code, _signal) => { aEx.on('close', (code, signal) => {
if(code !== 0){ if(code !== 0){
loggerLaunchSuite.error(`AssetExec exited with code ${code}, assuming error.`) loggerLaunchSuite.error(`AssetExec exited with code ${code}, assuming error.`)
showLaunchFailure('Error During Launch', 'See console (CTRL + Shift + i) for more details.') showLaunchFailure('Error During Launch', 'See console (CTRL + Shift + i) for more details.')
} }
}) })
loggerLaunchSuite.info('Validating files.') // Establish communications between the AssetExec and current process.
setLaunchDetails('Validating file integrity..') aEx.on('message', async (m) => {
const invalidFileCount = await fullRepairModule.verifyFiles(percent => {
setLaunchPercentage(percent)
})
setLaunchPercentage(100)
if(invalidFileCount > 0) { if(m.context === 'validate'){
loggerLaunchSuite.info('Downloading files.') switch(m.data){
setLaunchDetails('Downloading files..') case 'distribution':
await fullRepairModule.download(percent => { setLaunchPercentage(20, 100)
setDownloadPercentage(percent) loggerLaunchSuite.info('Validated distibution index.')
}) setLaunchDetails('Loading version information..')
setDownloadPercentage(100) break
} else { case 'version':
loggerLaunchSuite.info('No invalid files, skipping download.') setLaunchPercentage(40, 100)
} loggerLaunchSuite.info('Version data loaded.')
setLaunchDetails('Validating asset integrity..')
// Remove download bar. break
remote.getCurrentWindow().setProgressBar(-1) case 'assets':
setLaunchPercentage(60, 100)
fullRepairModule.destroyReceiver() loggerLaunchSuite.info('Asset Validation Complete')
setLaunchDetails('Validating library integrity..')
setLaunchDetails('Preparing to launch..') break
case 'libraries':
const mojangIndexProcessor = new MojangIndexProcessor( setLaunchPercentage(80, 100)
ConfigManager.getCommonDirectory(), loggerLaunchSuite.info('Library validation complete.')
serv.rawServer.minecraftVersion) setLaunchDetails('Validating miscellaneous file integrity..')
const distributionIndexProcessor = new DistributionIndexProcessor( break
ConfigManager.getCommonDirectory(), case 'files':
distro, setLaunchPercentage(100, 100)
serv.rawServer.id loggerLaunchSuite.info('File validation complete.')
) setLaunchDetails('Downloading files..')
break
// TODO need to load these.
const forgeData = await distributionIndexProcessor.loadForgeVersionJson(serv)
const versionData = await mojangIndexProcessor.getVersionJson()
if(login) {
const authUser = ConfigManager.getSelectedAccount()
loggerLaunchSuite.info(`Sending selected account (${authUser.displayName}) to ProcessBuilder.`)
let pb = new ProcessBuilder(serv, versionData, forgeData, authUser, remote.app.getVersion())
setLaunchDetails('Launching game..')
// const SERVER_JOINED_REGEX = /\[.+\]: \[CHAT\] [a-zA-Z0-9_]{1,16} joined the game/
const SERVER_JOINED_REGEX = new RegExp(`\\[.+\\]: \\[CHAT\\] ${authUser.displayName} joined the game`)
const onLoadComplete = () => {
toggleLaunchArea(false)
if(hasRPC){
DiscordWrapper.updateDetails('Loading game..')
} }
proc.stdout.on('data', gameStateChange) } else if(m.context === 'progress'){
proc.stdout.removeListener('data', tempListener) switch(m.data){
proc.stderr.removeListener('data', gameErrorListener) case 'assets': {
} const perc = (m.value/m.total)*20
const start = Date.now() setLaunchPercentage(40+perc, 100, parseInt(40+perc))
break
}
case 'download':
setDownloadPercentage(m.value, m.total, m.percent)
break
case 'extract': {
// Show installing progress bar.
remote.getCurrentWindow().setProgressBar(2)
// Attach a temporary listener to the client output. // Download done, extracting.
// Will wait for a certain bit of text meaning that const eLStr = 'Extracting libraries'
// the client application has started, and we can hide let dotStr = ''
// the progress bar stuff. setLaunchDetails(eLStr)
const tempListener = function(data){ progressListener = setInterval(() => {
if(GAME_LAUNCH_REGEX.test(data.trim())){ if(dotStr.length >= 3){
const diff = Date.now()-start dotStr = ''
if(diff < MIN_LINGER) { } else {
setTimeout(onLoadComplete, MIN_LINGER-diff) dotStr += '.'
} else { }
onLoadComplete() setLaunchDetails(eLStr + dotStr)
}, 750)
break
} }
} }
} } else if(m.context === 'complete'){
switch(m.data){
case 'download':
// Download and extraction complete, remove the loading from the OS progress bar.
remote.getCurrentWindow().setProgressBar(-1)
if(progressListener != null){
clearInterval(progressListener)
progressListener = null
}
// Listener for Discord RPC. setLaunchDetails('Preparing to launch..')
const gameStateChange = function(data){ break
data = data.trim()
if(SERVER_JOINED_REGEX.test(data)){
DiscordWrapper.updateDetails('Exploring the Realm!')
} else if(GAME_JOINED_REGEX.test(data)){
DiscordWrapper.updateDetails('Sailing to Westeros!')
} }
} } else if(m.context === 'error'){
switch(m.data){
case 'download':
loggerLaunchSuite.error('Error while downloading:', m.error)
const gameErrorListener = function(data){ if(m.error.code === 'ENOENT'){
data = data.trim() showLaunchFailure(
if(data.indexOf('Could not find or load main class net.minecraft.launchwrapper.Launch') > -1){ 'Download Error',
loggerLaunchSuite.error('Game launch failed, LaunchWrapper was not downloaded properly.') 'Could not connect to the file server. Ensure that you are connected to the internet and try again.'
showLaunchFailure('Error During Launch', 'The main file, LaunchWrapper, failed to download properly. As a result, the game cannot launch.<br><br>To fix this issue, temporarily turn off your antivirus software and launch the game again.<br><br>If you have time, please <a href="https://github.com/dscalzi/HeliosLauncher/issues">submit an issue</a> and let us know what antivirus software you use. We\'ll contact them and try to straighten things out.') )
} else {
showLaunchFailure(
'Download Error',
'Check the console (CTRL + Shift + i) for more details. Please try again.'
)
}
remote.getCurrentWindow().setProgressBar(-1)
// Disconnect from AssetExec
aEx.disconnect()
break
} }
} } else if(m.context === 'validateEverything'){
try { let allGood = true
// Build Minecraft process.
proc = pb.build()
// Bind listeners to stdout. // If these properties are not defined it's likely an error.
proc.stdout.on('data', tempListener) if(m.result.forgeData == null || m.result.versionData == null){
proc.stderr.on('data', gameErrorListener) loggerLaunchSuite.error('Error during validation:', m.result)
setLaunchDetails('Done. Enjoy the server!') loggerLaunchSuite.error('Error during launch', m.result.error)
showLaunchFailure('Error During Launch', 'Please check the console (CTRL + Shift + i) for more details.')
// Init Discord Hook allGood = false
if(distro.rawDistribution.discord != null && serv.rawServerdiscord != null){
DiscordWrapper.initRPC(distro.rawDistribution.discord, serv.rawServer.discord)
hasRPC = true
proc.on('close', (code, signal) => {
loggerLaunchSuite.info('Shutting down Discord Rich Presence..')
DiscordWrapper.shutdownRPC()
hasRPC = false
proc = null
})
} }
} catch(err) { forgeData = m.result.forgeData
versionData = m.result.versionData
loggerLaunchSuite.error('Error during launch', err) if(login && allGood) {
showLaunchFailure('Error During Launch', 'Please check the console (CTRL + Shift + i) for more details.') const authUser = ConfigManager.getSelectedAccount()
loggerLaunchSuite.info(`Sending selected account (${authUser.displayName}) to ProcessBuilder.`)
let pb = new ProcessBuilder(serv, versionData, forgeData, authUser, remote.app.getVersion())
setLaunchDetails('Launching game..')
// const SERVER_JOINED_REGEX = /\[.+\]: \[CHAT\] [a-zA-Z0-9_]{1,16} joined the game/
const SERVER_JOINED_REGEX = new RegExp(`\\[.+\\]: \\[CHAT\\] ${authUser.displayName} joined the game`)
const onLoadComplete = () => {
toggleLaunchArea(false)
if(hasRPC){
DiscordWrapper.updateDetails('Loading game..')
}
proc.stdout.on('data', gameStateChange)
proc.stdout.removeListener('data', tempListener)
proc.stderr.removeListener('data', gameErrorListener)
}
const start = Date.now()
// Attach a temporary listener to the client output.
// Will wait for a certain bit of text meaning that
// the client application has started, and we can hide
// the progress bar stuff.
const tempListener = function(data){
if(GAME_LAUNCH_REGEX.test(data.trim())){
const diff = Date.now()-start
if(diff < MIN_LINGER) {
setTimeout(onLoadComplete, MIN_LINGER-diff)
} else {
onLoadComplete()
}
}
}
// Listener for Discord RPC.
const gameStateChange = function(data){
data = data.trim()
if(SERVER_JOINED_REGEX.test(data)){
DiscordWrapper.updateDetails('Exploring the Realm!')
} else if(GAME_JOINED_REGEX.test(data)){
DiscordWrapper.updateDetails('Sailing to Westeros!')
}
}
const gameErrorListener = function(data){
data = data.trim()
if(data.indexOf('Could not find or load main class net.minecraft.launchwrapper.Launch') > -1){
loggerLaunchSuite.error('Game launch failed, LaunchWrapper was not downloaded properly.')
showLaunchFailure('Error During Launch', 'The main file, LaunchWrapper, failed to download properly. As a result, the game cannot launch.<br><br>To fix this issue, temporarily turn off your antivirus software and launch the game again.<br><br>If you have time, please <a href="https://github.com/dscalzi/HeliosLauncher/issues">submit an issue</a> and let us know what antivirus software you use. We\'ll contact them and try to straighten things out.')
}
}
try {
// Build Minecraft process.
proc = pb.build()
// Bind listeners to stdout.
proc.stdout.on('data', tempListener)
proc.stderr.on('data', gameErrorListener)
setLaunchDetails('Done. Enjoy the server!')
// Init Discord Hook
const distro = await DistroAPI.getDistribution()
if(distro.rawDistribution.discord != null && serv.rawServerdiscord != null){
DiscordWrapper.initRPC(distro.rawDistribution.discord, serv.rawServer.discord)
hasRPC = true
proc.on('close', (code, signal) => {
loggerLaunchSuite.info('Shutting down Discord Rich Presence..')
DiscordWrapper.shutdownRPC()
hasRPC = false
proc = null
})
}
} catch(err) {
loggerLaunchSuite.error('Error during launch', err)
showLaunchFailure('Error During Launch', 'Please check the console (CTRL + Shift + i) for more details.')
}
}
// Disconnect from AssetExec
aEx.disconnect()
} }
} })
// Begin Validations
// Validate Forge files.
setLaunchDetails('Loading server information..')
DistroAPI.refreshDistributionOrFallback()
.then(data => {
onDistroRefresh(data)
serv = data.getServerById(ConfigManager.getSelectedServer())
aEx.send({task: 'execute', function: 'validateEverything', argsArr: [ConfigManager.getSelectedServer(), DistroAPI.isDevMode()]})
})
.catch(err => {
loggerLaunchSuite.error('Unable to refresh distribution index.', err)
showLaunchFailure('Fatal Error', 'Could not load a copy of the distribution index. See the console (CTRL + Shift + i) for more details.')
// Disconnect from AssetExec
aEx.disconnect()
})
} }
/** /**