From 57bf2c58e897b46802ca63213e43b4ab395e7550 Mon Sep 17 00:00:00 2001 From: SrGooglo Date: Thu, 25 Jan 2024 00:37:16 +0100 Subject: [PATCH] added mcl lib --- src/main/lib/mcl/authenticator.js | 167 +++++++ src/main/lib/mcl/handler.js | 783 ++++++++++++++++++++++++++++++ src/main/lib/mcl/launcher.js | 223 +++++++++ 3 files changed, 1173 insertions(+) create mode 100644 src/main/lib/mcl/authenticator.js create mode 100644 src/main/lib/mcl/handler.js create mode 100644 src/main/lib/mcl/launcher.js diff --git a/src/main/lib/mcl/authenticator.js b/src/main/lib/mcl/authenticator.js new file mode 100644 index 0000000..c5973d8 --- /dev/null +++ b/src/main/lib/mcl/authenticator.js @@ -0,0 +1,167 @@ +const request = require('request') +const { v3 } = require('uuid') + +let uuid +let api_url = 'https://authserver.mojang.com' + +function parsePropts(array) { + if (array) { + const newObj = {} + for (const entry of array) { + if (newObj[entry.name]) { + newObj[entry.name].push(entry.value) + } else { + newObj[entry.name] = [entry.value] + } + } + return JSON.stringify(newObj) + } else { + return '{}' + } +} + +function getUUID(value) { + if (!uuid) { + uuid = v3(value, v3.DNS) + } + return uuid +} + +const Authenticator = { + getAuth: (username, password, client_token = null) => { + return new Promise((resolve, reject) => { + getUUID(username) + if (!password) { + const user = { + access_token: uuid, + client_token: client_token || uuid, + uuid, + name: username, + user_properties: '{}' + } + + return resolve(user) + } + + const requestObject = { + url: api_url + '/authenticate', + json: { + agent: { + name: 'Minecraft', + version: 1 + }, + username, + password, + clientToken: uuid, + requestUser: true + } + } + + request.post(requestObject, function (error, response, body) { + if (error) return reject(error) + if (!body || !body.selectedProfile) { + return reject(new Error('Validation error: ' + response.statusMessage)) + } + + const userProfile = { + access_token: body.accessToken, + client_token: body.clientToken, + uuid: body.selectedProfile.id, + name: body.selectedProfile.name, + selected_profile: body.selectedProfile, + user_properties: parsePropts(body.user.properties) + } + + resolve(userProfile) + }) + }) + }, + validate: (accessToken, clientToken) => { + return new Promise((resolve, reject) => { + const requestObject = { + url: api_url + '/validate', + json: { + accessToken, + clientToken + } + } + + request.post(requestObject, async function (error, response, body) { + if (error) return reject(error) + + if (!body) resolve(true) + else reject(body) + }) + }) + }, + refreshAuth: (accessToken, clientToken) => { + return new Promise((resolve, reject) => { + const requestObject = { + url: api_url + '/refresh', + json: { + accessToken, + clientToken, + requestUser: true + } + } + + request.post(requestObject, function (error, response, body) { + if (error) return reject(error) + if (!body || !body.selectedProfile) { + return reject(new Error('Validation error: ' + response.statusMessage)) + } + + const userProfile = { + access_token: body.accessToken, + client_token: getUUID(body.selectedProfile.name), + uuid: body.selectedProfile.id, + name: body.selectedProfile.name, + user_properties: parsePropts(body.user.properties) + } + + return resolve(userProfile) + }) + }) + }, + invalidate: (accessToken, clientToken) => { + return new Promise((resolve, reject) => { + const requestObject = { + url: api_url + '/invalidate', + json: { + accessToken, + clientToken + } + } + + request.post(requestObject, function (error, response, body) { + if (error) return reject(error) + + if (!body) return resolve(true) + else return reject(body) + }) + }) + }, + signOut: (username, password) => { + return new Promise((resolve, reject) => { + const requestObject = { + url: api_url + '/signout', + json: { + username, + password + } + } + + request.post(requestObject, function (error, response, body) { + if (error) return reject(error) + + if (!body) return resolve(true) + else return reject(body) + }) + }) + }, + changeApiUrl: (url) => { + api_url = url + } +} + +export default Authenticator \ No newline at end of file diff --git a/src/main/lib/mcl/handler.js b/src/main/lib/mcl/handler.js new file mode 100644 index 0000000..ca0a477 --- /dev/null +++ b/src/main/lib/mcl/handler.js @@ -0,0 +1,783 @@ +const fs = require('fs') +const path = require('path') +const request = require('request') +const checksum = require('checksum') +const Zip = require('adm-zip') +const child = require('child_process') +let counter = 0 + +export default class Handler { + constructor (client) { + this.client = client + this.options = client.options + this.baseRequest = request.defaults({ + pool: { maxSockets: this.options.overrides.maxSockets || 2 }, + timeout: this.options.timeout || 10000 + }) + } + + checkJava (java) { + return new Promise(resolve => { + child.exec(`"${java}" -version`, (error, stdout, stderr) => { + if (error) { + resolve({ + run: false, + message: error + }) + } else { + this.client.emit('debug', `[MCLC]: Using Java version ${stderr.match(/"(.*?)"/).pop()} ${stderr.includes('64-Bit') ? '64-bit' : '32-Bit'}`) + resolve({ + run: true + }) + } + }) + }) + } + + downloadAsync (url, directory, name, retry, type) { + return new Promise(resolve => { + fs.mkdirSync(directory, { recursive: true }) + + const _request = this.baseRequest(url) + + let receivedBytes = 0 + let totalBytes = 0 + + _request.on('response', (data) => { + if (data.statusCode === 404) { + this.client.emit('debug', `[MCLC]: Failed to download ${url} due to: File not found...`) + return resolve(false) + } + + totalBytes = parseInt(data.headers['content-length']) + }) + + _request.on('error', async (error) => { + this.client.emit('debug', `[MCLC]: Failed to download asset to ${path.join(directory, name)} due to\n${error}.` + + ` Retrying... ${retry}`) + if (retry) await this.downloadAsync(url, directory, name, false, type) + resolve() + }) + + _request.on('data', (data) => { + receivedBytes += data.length + this.client.emit('download-status', { + name: name, + type: type, + current: receivedBytes, + total: totalBytes + }) + }) + + const file = fs.createWriteStream(path.join(directory, name)) + _request.pipe(file) + + file.once('finish', () => { + this.client.emit('download', name) + resolve({ + failed: false, + asset: null + }) + }) + + file.on('error', async (e) => { + this.client.emit('debug', `[MCLC]: Failed to download asset to ${path.join(directory, name)} due to\n${e}.` + + ` Retrying... ${retry}`) + if (fs.existsSync(path.join(directory, name))) fs.unlinkSync(path.join(directory, name)) + if (retry) await this.downloadAsync(url, directory, name, false, type) + resolve() + }) + }) + } + + checkSum (hash, file) { + return new Promise((resolve, reject) => { + checksum.file(file, (err, sum) => { + if (err) { + this.client.emit('debug', `[MCLC]: Failed to check file hash due to ${err}`) + resolve(false) + } else { + resolve(hash === sum) + } + }) + }) + } + + getVersion () { + return new Promise(resolve => { + const versionJsonPath = this.options.overrides.versionJson || path.join(this.options.directory, `${this.options.version.number}.json`) + + if (fs.existsSync(versionJsonPath)) { + this.version = JSON.parse(fs.readFileSync(versionJsonPath)) + + return resolve(this.version) + } + + const manifest = `${this.options.overrides.url.meta}/mc/game/version_manifest.json` + + const cache = this.options.cache ? `${this.options.cache}/json` : `${this.options.root}/cache/json` + + request.get(manifest, (error, response, body) => { + if (error && error.code !== 'ENOTFOUND') { + return resolve(error) + } + + if (!error) { + if (!fs.existsSync(cache)) { + fs.mkdirSync(cache, { recursive: true }) + + this.client.emit('debug', '[MCLC]: Cache directory created.') + } + + fs.writeFile(path.join(`${cache}/version_manifest.json`), body, (err) => { + if (err) { + return resolve(err) + } + + this.client.emit('debug', '[MCLC]: Cached version_manifest.json (from request)') + }) + } + + let parsed = null + + if (error && (error.code === 'ENOTFOUND')) { + parsed = JSON.parse(fs.readFileSync(`${cache}/version_manifest.json`)) + } else { + parsed = JSON.parse(body) + } + + const versionManifest = parsed.versions.find((version) => { + return version.id === this.options.version.number + }) + + if (!versionManifest) { + return resolve(new Error(`Version not found`)) + } + + request.get(versionManifest.url, (error, response, body) => { + if (error && error.code !== 'ENOTFOUND') { + return resolve(error) + } + + if (!error) { + fs.writeFile(path.join(`${cache}/${this.options.version.number}.json`), body, (err) => { + if (err) { + return resolve(err) + } + + this.client.emit('debug', `[MCLC]: Cached ${this.options.version.number}.json`) + }) + } + + this.client.emit('debug', '[MCLC]: Parsed version from version manifest') + + if (error && (error.code === 'ENOTFOUND')) { + this.version = JSON.parse(fs.readFileSync(`${cache}/${this.options.version.number}.json`)) + } else { + this.version = JSON.parse(body) + } + + this.client.emit('debug', this.version) + + return resolve(this.version) + }) + }) + }) + } + + async getJar () { + await this.downloadAsync(this.version.downloads.client.url, this.options.directory, `${this.options.version.custom ? this.options.version.custom : this.options.version.number}.jar`, true, 'version-jar') + fs.writeFileSync(path.join(this.options.directory, `${this.options.version.number}.json`), JSON.stringify(this.version, null, 4)) + return this.client.emit('debug', '[MCLC]: Downloaded version jar and wrote version json') + } + + async getAssets () { + const assetDirectory = path.resolve(this.options.overrides.assetRoot || path.join(this.options.root, 'assets')) + const assetId = this.options.version.custom || this.options.version.number + if (!fs.existsSync(path.join(assetDirectory, 'indexes', `${assetId}.json`))) { + await this.downloadAsync(this.version.assetIndex.url, path.join(assetDirectory, 'indexes'), + `${assetId}.json`, true, 'asset-json') + } + + const index = JSON.parse(fs.readFileSync(path.join(assetDirectory, 'indexes', `${assetId}.json`), { encoding: 'utf8' })) + + this.client.emit('progress', { + type: 'assets', + task: 0, + total: Object.keys(index.objects).length + }) + + await Promise.all(Object.keys(index.objects).map(async asset => { + const hash = index.objects[asset].hash + const subhash = hash.substring(0, 2) + const subAsset = path.join(assetDirectory, 'objects', subhash) + + if (!fs.existsSync(path.join(subAsset, hash)) || !await this.checkSum(hash, path.join(subAsset, hash))) { + await this.downloadAsync(`${this.options.overrides.url.resource}/${subhash}/${hash}`, subAsset, hash, + true, 'assets') + } + counter++ + this.client.emit('progress', { + type: 'assets', + task: counter, + total: Object.keys(index.objects).length + }) + })) + counter = 0 + + // Copy assets to legacy if it's an older Minecraft version. + if (this.isLegacy()) { + if (fs.existsSync(path.join(assetDirectory, 'legacy'))) { + this.client.emit('debug', '[MCLC]: The \'legacy\' directory is no longer used as Minecraft looks ' + + 'for the resouces folder regardless of what is passed in the assetDirecotry launch option. I\'d ' + + `recommend removing the directory (${path.join(assetDirectory, 'legacy')})`) + } + + const legacyDirectory = path.join(this.options.root, 'resources') + this.client.emit('debug', `[MCLC]: Copying assets over to ${legacyDirectory}`) + + this.client.emit('progress', { + type: 'assets-copy', + task: 0, + total: Object.keys(index.objects).length + }) + + await Promise.all(Object.keys(index.objects).map(async asset => { + const hash = index.objects[asset].hash + const subhash = hash.substring(0, 2) + const subAsset = path.join(assetDirectory, 'objects', subhash) + + const legacyAsset = asset.split('/') + legacyAsset.pop() + + if (!fs.existsSync(path.join(legacyDirectory, legacyAsset.join('/')))) { + fs.mkdirSync(path.join(legacyDirectory, legacyAsset.join('/')), { recursive: true }) + } + + if (!fs.existsSync(path.join(legacyDirectory, asset))) { + fs.copyFileSync(path.join(subAsset, hash), path.join(legacyDirectory, asset)) + } + counter++ + this.client.emit('progress', { + type: 'assets-copy', + task: counter, + total: Object.keys(index.objects).length + }) + })) + } + counter = 0 + + this.client.emit('debug', '[MCLC]: Downloaded assets') + } + + parseRule (lib) { + if (lib.rules) { + if (lib.rules.length > 1) { + if (lib.rules[0].action === 'allow' && + lib.rules[1].action === 'disallow' && + lib.rules[1].os.name === 'osx') { + return this.getOS() === 'osx' + } else { + return true + } + } else { + if (lib.rules[0].action === 'allow' && lib.rules[0].os) return lib.rules[0].os.name !== this.getOS() + } + } else { + return false + } + } + + async getNatives () { + const nativeDirectory = path.resolve(this.options.overrides.natives || path.join(this.options.root, 'natives', this.version.id)) + + if (parseInt(this.version.id.split('.')[1]) >= 19) return this.options.overrides.cwd || this.options.root + + if (!fs.existsSync(nativeDirectory) || !fs.readdirSync(nativeDirectory).length) { + fs.mkdirSync(nativeDirectory, { recursive: true }) + + const natives = async () => { + const natives = [] + await Promise.all(this.version.libraries.map(async (lib) => { + if (!lib.downloads || !lib.downloads.classifiers) return + if (this.parseRule(lib)) return + + const native = this.getOS() === 'osx' + ? lib.downloads.classifiers['natives-osx'] || lib.downloads.classifiers['natives-macos'] + : lib.downloads.classifiers[`natives-${this.getOS()}`] + + natives.push(native) + })) + return natives + } + const stat = await natives() + + this.client.emit('progress', { + type: 'natives', + task: 0, + total: stat.length + }) + + await Promise.all(stat.map(async (native) => { + if (!native) return + const name = native.path.split('/').pop() + await this.downloadAsync(native.url, nativeDirectory, name, true, 'natives') + if (!await this.checkSum(native.sha1, path.join(nativeDirectory, name))) { + await this.downloadAsync(native.url, nativeDirectory, name, true, 'natives') + } + try { + new Zip(path.join(nativeDirectory, name)).extractAllTo(nativeDirectory, true) + } catch (e) { + // Only doing a console.warn since a stupid error happens. You can basically ignore this. + // if it says Invalid file name, just means two files were downloaded and both were deleted. + // All is well. + console.warn(e) + } + fs.unlinkSync(path.join(nativeDirectory, name)) + counter++ + this.client.emit('progress', { + type: 'natives', + task: counter, + total: stat.length + }) + })) + this.client.emit('debug', '[MCLC]: Downloaded and extracted natives') + } + + counter = 0 + this.client.emit('debug', `[MCLC]: Set native path to ${nativeDirectory}`) + + return nativeDirectory + } + + fwAddArgs () { + const forgeWrapperAgrs = [ + `-Dforgewrapper.librariesDir=${path.resolve(this.options.overrides.libraryRoot || path.join(this.options.root, 'libraries'))}`, + `-Dforgewrapper.installer=${this.options.forge}`, + `-Dforgewrapper.minecraft=${this.options.mcPath}` + ] + this.options.customArgs + ? this.options.customArgs = this.options.customArgs.concat(forgeWrapperAgrs) + : this.options.customArgs = forgeWrapperAgrs + } + + isModernForge (json) { + return json.inheritsFrom && json.inheritsFrom.split('.')[1] >= 12 && !(json.inheritsFrom === '1.12.2' && (json.id.split('.')[json.id.split('.').length - 1]) === '2847') + } + + async getForgedWrapped () { + let json = null + let installerJson = null + const versionPath = path.join(this.options.root, 'forge', `${this.version.id}`, 'version.json') + // Since we're building a proper "custom" JSON that will work nativly with MCLC, the version JSON will not + // be re-generated on the next run. + if (fs.existsSync(versionPath)) { + try { + json = JSON.parse(fs.readFileSync(versionPath)) + if (!json.forgeWrapperVersion || !(json.forgeWrapperVersion === this.options.overrides.fw.version)) { + this.client.emit('debug', '[MCLC]: Old ForgeWrapper has generated this version JSON, re-generating') + } else { + // If forge is modern, add ForgeWrappers launch arguments and set forge to null so MCLC treats it as a custom json. + if (this.isModernForge(json)) { + this.fwAddArgs() + this.options.forge = null + } + return json + } + } catch (e) { + console.warn(e) + this.client.emit('debug', '[MCLC]: Failed to parse Forge version JSON, re-generating') + } + } + + this.client.emit('debug', '[MCLC]: Generating a proper version json, this might take a bit') + const zipFile = new Zip(this.options.forge) + json = zipFile.readAsText('version.json') + if (zipFile.getEntry('install_profile.json')) installerJson = zipFile.readAsText('install_profile.json') + + try { + json = JSON.parse(json) + if (installerJson) installerJson = JSON.parse(installerJson) + } catch (e) { + this.client.emit('debug', '[MCLC]: Failed to load json files for ForgeWrapper, using Vanilla instead') + return null + } + // Adding the installer libraries as mavenFiles so MCLC downloads them but doesn't add them to the class paths. + if (installerJson) { + json.mavenFiles + ? json.mavenFiles = json.mavenFiles.concat(installerJson.libraries) + : json.mavenFiles = installerJson.libraries + } + + // Holder for the specifc jar ending which depends on the specifc forge version. + let jarEnding = 'universal' + // We need to handle modern forge differently than legacy. + if (this.isModernForge(json)) { + // If forge is modern and above 1.12.2, we add ForgeWrapper to the libraries so MCLC includes it in the classpaths. + if (json.inheritsFrom !== '1.12.2') { + this.fwAddArgs() + const fwName = `ForgeWrapper-${this.options.overrides.fw.version}.jar` + const fwPathArr = ['io', 'github', 'zekerzhayard', 'ForgeWrapper', this.options.overrides.fw.version] + json.libraries.push({ + name: fwPathArr.join(':'), + downloads: { + artifact: { + path: [...fwPathArr, fwName].join('/'), + url: `${this.options.overrides.fw.baseUrl}${this.options.overrides.fw.version}/${fwName}`, + sha1: this.options.overrides.fw.sh1, + size: this.options.overrides.fw.size + } + } + }) + json.mainClass = 'io.github.zekerzhayard.forgewrapper.installer.Main' + jarEnding = 'launcher' + + // Providing a download URL to the universal jar mavenFile so it can be downloaded properly. + for (const library of json.mavenFiles) { + const lib = library.name.split(':') + if (lib[0] === 'net.minecraftforge' && lib[1].includes('forge')) { + library.downloads.artifact.url = 'https://files.minecraftforge.net/maven/' + library.downloads.artifact.path + break + } + } + } else { + // Remove the forge dependent since we're going to overwrite the first entry anyways. + for (const library in json.mavenFiles) { + const lib = json.mavenFiles[library].name.split(':') + if (lib[0] === 'net.minecraftforge' && lib[1].includes('forge')) { + delete json.mavenFiles[library] + break + } + } + } + } else { + // Modifying legacy library format to play nice with MCLC's downloadToDirectory function. + await Promise.all(json.libraries.map(async library => { + const lib = library.name.split(':') + if (lib[0] === 'net.minecraftforge' && lib[1].includes('forge')) return + + let url = this.options.overrides.url.mavenForge + const name = `${lib[1]}-${lib[2]}.jar` + + if (!library.url) { + if (library.serverreq || library.clientreq) { + url = this.options.overrides.url.defaultRepoForge + } else { + return + } + } + library.url = url + const downloadLink = `${url}${lib[0].replace(/\./g, '/')}/${lib[1]}/${lib[2]}/${name}` + // Checking if the file still exists on Forge's server, if not, replace it with the fallback. + // Not checking for sucess, only if it 404s. + this.baseRequest(downloadLink, (error, response, body) => { + if (error) { + this.client.emit('debug', `[MCLC]: Failed checking request for ${downloadLink}`) + } else { + if (response.statusCode === 404) library.url = this.options.overrides.url.fallbackMaven + } + }) + })) + } + // If a downloads property exists, we modify the inital forge entry to include ${jarEnding} so ForgeWrapper can work properly. + // If it doesn't, we simply remove it since we're already providing the universal jar. + if (json.libraries[0].downloads) { + if (json.libraries[0].name.includes('minecraftforge')) { + json.libraries[0].name = json.libraries[0].name + `:${jarEnding}` + json.libraries[0].downloads.artifact.path = json.libraries[0].downloads.artifact.path.replace('.jar', `-${jarEnding}.jar`) + json.libraries[0].downloads.artifact.url = 'https://files.minecraftforge.net/maven/' + json.libraries[0].downloads.artifact.path + } + } else { + delete json.libraries[0] + } + + // Removing duplicates and null types + json.libraries = this.cleanUp(json.libraries) + if (json.mavenFiles) json.mavenFiles = this.cleanUp(json.mavenFiles) + + json.forgeWrapperVersion = this.options.overrides.fw.version + + // Saving file for next run! + if (!fs.existsSync(path.join(this.options.root, 'forge', this.version.id))) { + fs.mkdirSync(path.join(this.options.root, 'forge', this.version.id), { recursive: true }) + } + fs.writeFileSync(versionPath, JSON.stringify(json, null, 4)) + + // Make MCLC treat modern forge as a custom version json rather then legacy forge. + if (this.isModernForge(json)) this.options.forge = null + + return json + } + + runInstaller (path) { + return new Promise(resolve => { + const installer = child.exec(path) + installer.on('close', (code) => resolve(code)) + }) + } + + async downloadToDirectory (directory, libraries, eventName) { + const libs = [] + + await Promise.all(libraries.map(async library => { + if (!library) return + if (this.parseRule(library)) return + const lib = library.name.split(':') + + let jarPath + let name + if (library.downloads && library.downloads.artifact && library.downloads.artifact.path) { + name = library.downloads.artifact.path.split('/')[library.downloads.artifact.path.split('/').length - 1] + jarPath = path.join(directory, this.popString(library.downloads.artifact.path)) + } else { + name = `${lib[1]}-${lib[2]}${lib[3] ? '-' + lib[3] : ''}.jar` + jarPath = path.join(directory, `${lib[0].replace(/\./g, '/')}/${lib[1]}/${lib[2]}`) + } + + const downloadLibrary = async library => { + if (library.url) { + const url = `${library.url}${lib[0].replace(/\./g, '/')}/${lib[1]}/${lib[2]}/${name}` + await this.downloadAsync(url, jarPath, name, true, eventName) + } else if (library.downloads && library.downloads.artifact) { + await this.downloadAsync(library.downloads.artifact.url, jarPath, name, true, eventName) + } + } + + if (!fs.existsSync(path.join(jarPath, name))) downloadLibrary(library) + else if (library.downloads && library.downloads.artifact) { + if (!this.checkSum(library.downloads.artifact.sha1, path.join(jarPath, name))) downloadLibrary(library) + } + + counter++ + this.client.emit('progress', { + type: eventName, + task: counter, + total: libraries.length + }) + libs.push(`${jarPath}${path.sep}${name}`) + })) + counter = 0 + + return libs + } + + async getClasses (classJson) { + let libs = [] + + const libraryDirectory = path.resolve(this.options.overrides.libraryRoot || path.join(this.options.root, 'libraries')) + + if (classJson) { + if (classJson.mavenFiles) { + await this.downloadToDirectory(libraryDirectory, classJson.mavenFiles, 'classes-maven-custom') + } + libs = (await this.downloadToDirectory(libraryDirectory, classJson.libraries, 'classes-custom')) + } + + const parsed = this.version.libraries.map(lib => { + if (lib.downloads && lib.downloads.artifact && !this.parseRule(lib)) return lib + }) + + libs = libs.concat((await this.downloadToDirectory(libraryDirectory, parsed, 'classes'))) + counter = 0 + + // Temp Quilt support + if (classJson) libs.sort() + + this.client.emit('debug', '[MCLC]: Collected class paths') + return libs + } + + popString (path) { + const tempArray = path.split('/') + tempArray.pop() + return tempArray.join('/') + } + + cleanUp (array) { + const newArray = [] + for (const classPath in array) { + if (newArray.includes(array[classPath]) || array[classPath] === null) continue + newArray.push(array[classPath]) + } + return newArray + } + + formatQuickPlay () { + const types = { + singleplayer: '--quickPlaySingleplayer', + multiplayer: '--quickPlayMultiplayer', + realms: '--quickPlayRealms', + legacy: null + } + const { type, identifier, path } = this.options.quickPlay + const keys = Object.keys(types) + if (!keys.includes(type)) { + this.client.emit('debug', `[MCLC]: quickPlay type is not valid. Valid types are: ${keys.join(', ')}`) + return null + } + const returnArgs = type === 'legacy' + ? ['--server', identifier.split(':')[0], '--port', identifier.split(':')[1] || '25565'] + : [types[type], identifier] + if (path) returnArgs.push('--quickPlayPath', path) + return returnArgs + } + + async getLaunchOptions (modification) { + const type = Object.assign({}, this.version, modification) + + let args = type.minecraftArguments + ? type.minecraftArguments.split(' ') + : type.arguments.game + const assetRoot = path.resolve(this.options.overrides.assetRoot || path.join(this.options.root, 'assets')) + const assetPath = this.isLegacy() + ? path.join(this.options.root, 'resources') + : path.join(assetRoot) + + const minArgs = this.options.overrides.minArgs || this.isLegacy() ? 5 : 11 + if (args.length < minArgs) args = args.concat(this.version.minecraftArguments ? this.version.minecraftArguments.split(' ') : this.version.arguments.game) + if (this.options.customLaunchArgs) args = args.concat(this.options.customLaunchArgs) + + this.options.authorization = await Promise.resolve(this.options.authorization) + this.options.authorization.meta = this.options.authorization.meta ? this.options.authorization.meta : { type: 'mojang' } + const fields = { + '${auth_access_token}': this.options.authorization.access_token, + '${auth_session}': this.options.authorization.access_token, + '${auth_player_name}': this.options.authorization.name, + '${auth_uuid}': this.options.authorization.uuid, + '${auth_xuid}': this.options.authorization.meta.xuid || this.options.authorization.access_token, + '${user_properties}': this.options.authorization.user_properties, + '${user_type}': this.options.authorization.meta.type, + '${version_name}': this.options.version.number, + '${assets_index_name}': this.options.overrides.assetIndex || this.options.version.custom || this.options.version.number, + '${game_directory}': this.options.overrides.gameDirectory || this.options.root, + '${assets_root}': assetPath, + '${game_assets}': assetPath, + '${version_type}': this.options.version.type, + '${clientid}': this.options.authorization.meta.clientId || (this.options.authorization.client_token || this.options.authorization.access_token), + '${resolution_width}': this.options.window ? this.options.window.width : 856, + '${resolution_height}': this.options.window ? this.options.window.height : 482 + } + + if (this.options.authorization.meta.demo && (this.options.features ? !this.options.features.includes('is_demo_user') : true)) { + args.push('--demo') + } + + const replaceArg = (obj, index) => { + if (Array.isArray(obj.value)) { + for (const arg of obj.value) { + args.push(arg) + } + } else { + args.push(obj.value) + } + delete args[index] + } + + for (let index = 0; index < args.length; index++) { + if (typeof args[index] === 'object') { + if (args[index].rules) { + if (!this.options.features) continue + const featureFlags = [] + for (const rule of args[index].rules) { + featureFlags.push(...Object.keys(rule.features)) + } + let hasAllRules = true + for (const feature of this.options.features) { + if (!featureFlags.includes(feature)) { + hasAllRules = false + } + } + if (hasAllRules) replaceArg(args[index], index) + } else { + replaceArg(args[index], index) + } + } else { + if (Object.keys(fields).includes(args[index])) { + args[index] = fields[args[index]] + } + } + } + if (this.options.window) { + // eslint-disable-next-line no-unused-expressions + this.options.window.fullscreen + ? args.push('--fullscreen') + : () => { + if (this.options.features ? !this.options.features.includes('has_custom_resolution') : true) { + args.push('--width', this.options.window.width, '--height', this.options.window.height) + } + } + } + if (this.options.server) this.client.emit('debug', '[MCLC]: server and port are deprecated launch flags. Use the quickPlay field.') + if (this.options.quickPlay) args = args.concat(this.formatQuickPlay()) + if (this.options.proxy) { + args.push( + '--proxyHost', + this.options.proxy.host, + '--proxyPort', + this.options.proxy.port || '8080', + '--proxyUser', + this.options.proxy.username, + '--proxyPass', + this.options.proxy.password + ) + } + args = args.filter(value => typeof value === 'string' || typeof value === 'number') + this.client.emit('debug', '[MCLC]: Set launch options') + return args + } + + async getJVM () { + const opts = { + windows: '-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump', + osx: '-XstartOnFirstThread', + linux: '-Xss1M' + } + return opts[this.getOS()] + } + + isLegacy () { + return this.version.assets === 'legacy' || this.version.assets === 'pre-1.6' + } + + getOS () { + if (this.options.os) { + return this.options.os + } else { + switch (process.platform) { + case 'win32': return 'windows' + case 'darwin': return 'osx' + default: return 'linux' + } + } + } + + // To prevent launchers from breaking when they update. Will be reworked with rewrite. + getMemory () { + if (!this.options.memory) { + this.client.emit('debug', '[MCLC]: Memory not set! Setting 1GB as MAX!') + this.options.memory = { + min: 512, + max: 1023 + } + } + if (!isNaN(this.options.memory.max) && !isNaN(this.options.memory.min)) { + if (this.options.memory.max < this.options.memory.min) { + this.client.emit('debug', '[MCLC]: MIN memory is higher then MAX! Resetting!') + this.options.memory.max = 1023 + this.options.memory.min = 512 + } + return [`${this.options.memory.max}M`, `${this.options.memory.min}M`] + } else { return [`${this.options.memory.max}`, `${this.options.memory.min}`] } + } + + async extractPackage (options = this.options) { + if (options.clientPackage.startsWith('http')) { + await this.downloadAsync(options.clientPackage, options.root, 'clientPackage.zip', true, 'client-package') + options.clientPackage = path.join(options.root, 'clientPackage.zip') + } + new Zip(options.clientPackage).extractAllTo(options.root, true) + if (options.removePackage) fs.unlinkSync(options.clientPackage) + + return this.client.emit('package-extract', true) + } +} \ No newline at end of file diff --git a/src/main/lib/mcl/launcher.js b/src/main/lib/mcl/launcher.js new file mode 100644 index 0000000..45271f6 --- /dev/null +++ b/src/main/lib/mcl/launcher.js @@ -0,0 +1,223 @@ +import fs from "node:fs" +import path from "node:path" +import {EventEmitter} from "events" +import child from "child_process" + +import Handler from "./handler" + +export default class MCLCore extends EventEmitter { + async launch(options, callbacks = {}) { + try { + this.options = { ...options } + + this.options.root = path.resolve(this.options.root) + + this.options.overrides = { + detached: true, + ...this.options.overrides, + url: { + meta: 'https://launchermeta.mojang.com', + resource: 'https://resources.download.minecraft.net', + mavenForge: 'http://files.minecraftforge.net/maven/', + defaultRepoForge: 'https://libraries.minecraft.net/', + fallbackMaven: 'https://search.maven.org/remotecontent?filepath=', + ...this.options.overrides + ? this.options.overrides.url + : undefined + }, + fw: { + baseUrl: 'https://github.com/ZekerZhayard/ForgeWrapper/releases/download/', + version: '1.5.6', + sh1: 'b38d28e8b7fde13b1bc0db946a2da6760fecf98d', + size: 34715, + ...this.options.overrides + ? this.options.overrides.fw + : undefined + } + } + + this.handler = new Handler(this) + + this.printVersion() + + const java = await this.handler.checkJava(this.options.javaPath || 'java') + + if (!java.run) { + this.emit('debug', `[MCLC]: Couldn't start Minecraft due to: ${java.message}`) + this.emit('close', 1) + return null + } + + this.createRootDirectory() + this.createGameDirectory() + + await this.extractPackage() + + if (this.options.installer) { + // So installers that create a profile in launcher_profiles.json can run without breaking. + const profilePath = path.join(this.options.root, 'launcher_profiles.json') + if (!fs.existsSync(profilePath) || !JSON.parse(fs.readFileSync(profilePath)).profiles) { + fs.writeFileSync(profilePath, JSON.stringify({ profiles: {} }, null, 4)) + } + const code = await this.handler.runInstaller(this.options.installer) + if (!this.options.version.custom && code === 0) { + this.emit('debug', '[MCLC]: Installer successfully ran, but no custom version was provided') + } + this.emit('debug', `[MCLC]: Installer closed with code ${code}`) + } + + const directory = this.options.overrides.directory || path.join(this.options.root, 'versions', this.options.version.custom ? this.options.version.custom : this.options.version.number) + this.options.directory = directory + + const versionFile = await this.handler.getVersion() + + const mcPath = this.options.overrides.minecraftJar || (this.options.version.custom + ? path.join(this.options.root, 'versions', this.options.version.custom, `${this.options.version.custom}.jar`) + : path.join(directory, `${this.options.version.number}.jar`)) + + this.options.mcPath = mcPath + + const nativePath = await this.handler.getNatives() + + if (!fs.existsSync(mcPath)) { + this.emit('debug', '[MCLC]: Attempting to download Minecraft version jar') + + if (typeof callbacks.install === "function") { + callbacks.install() + } + + await this.handler.getJar() + } + + const modifyJson = await this.getModifyJson() + + const args = [] + + let jvm = [ + '-XX:-UseAdaptiveSizePolicy', + '-XX:-OmitStackTraceInFastThrow', + '-Dfml.ignorePatchDiscrepancies=true', + '-Dfml.ignoreInvalidMinecraftCertificates=true', + `-Djava.library.path=${nativePath}`, + `-Xmx${this.handler.getMemory()[0]}`, + `-Xms${this.handler.getMemory()[1]}` + ] + if (this.handler.getOS() === 'osx') { + if (parseInt(versionFile.id.split('.')[1]) > 12) jvm.push(await this.handler.getJVM()) + } else jvm.push(await this.handler.getJVM()) + + if (this.options.customArgs) jvm = jvm.concat(this.options.customArgs) + if (this.options.overrides.logj4ConfigurationFile) { + jvm.push(`-Dlog4j.configurationFile=${path.resolve(this.options.overrides.logj4ConfigurationFile)}`) + } + // https://help.minecraft.net/hc/en-us/articles/4416199399693-Security-Vulnerability-in-Minecraft-Java-Edition + if (parseInt(versionFile.id.split('.')[1]) === 18 && !parseInt(versionFile.id.split('.')[2])) jvm.push('-Dlog4j2.formatMsgNoLookups=true') + if (parseInt(versionFile.id.split('.')[1]) === 17) jvm.push('-Dlog4j2.formatMsgNoLookups=true') + if (parseInt(versionFile.id.split('.')[1]) < 17) { + if (!jvm.find(arg => arg.includes('Dlog4j.configurationFile'))) { + const configPath = path.resolve(this.options.overrides.cwd || this.options.root) + const intVersion = parseInt(versionFile.id.split('.')[1]) + if (intVersion >= 12) { + await this.handler.downloadAsync('https://launcher.mojang.com/v1/objects/02937d122c86ce73319ef9975b58896fc1b491d1/log4j2_112-116.xml', + configPath, 'log4j2_112-116.xml', true, 'log4j') + jvm.push('-Dlog4j.configurationFile=log4j2_112-116.xml') + } else if (intVersion >= 7) { + await this.handler.downloadAsync('https://launcher.mojang.com/v1/objects/dd2b723346a8dcd48e7f4d245f6bf09e98db9696/log4j2_17-111.xml', + configPath, 'log4j2_17-111.xml', true, 'log4j') + jvm.push('-Dlog4j.configurationFile=log4j2_17-111.xml') + } + } + } + + const classes = this.options.overrides.classes || this.handler.cleanUp(await this.handler.getClasses(modifyJson)) + const classPaths = ['-cp'] + const separator = this.handler.getOS() === 'windows' ? ';' : ':' + + this.emit('debug', `[MCLC]: Using ${separator} to separate class paths`) + + // Handling launch arguments. + const file = modifyJson || versionFile + + // So mods like fabric work. + const jar = fs.existsSync(mcPath) + ? `${separator}${mcPath}` + : `${separator}${path.join(directory, `${this.options.version.number}.jar`)}` + classPaths.push(`${this.options.forge ? this.options.forge + separator : ''}${classes.join(separator)}${jar}`) + classPaths.push(file.mainClass) + + this.emit('debug', '[MCLC]: Attempting to download assets') + + if (typeof callbacks.init_assets === "function") { + callbacks.init_assets() + } + + await this.handler.getAssets() + + // Forge -> Custom -> Vanilla + const launchOptions = await this.handler.getLaunchOptions(modifyJson) + + const launchArguments = args.concat(jvm, classPaths, launchOptions) + this.emit('arguments', launchArguments) + this.emit('debug', `[MCLC]: Launching with arguments ${launchArguments.join(' ')}`) + + return this.startMinecraft(launchArguments) + } catch (e) { + this.emit('debug', `[MCLC]: Failed to start due to ${e}, closing...`) + return null + } + } + + printVersion() { + if (fs.existsSync(path.join(__dirname, '..', 'package.json'))) { + const { version } = require('../package.json') + this.emit('debug', `[MCLC]: MCLC version ${version}`) + } else { this.emit('debug', '[MCLC]: Package JSON not found, skipping MCLC version check.') } + } + + createRootDirectory() { + if (!fs.existsSync(this.options.root)) { + this.emit('debug', '[MCLC]: Attempting to create root folder') + fs.mkdirSync(this.options.root) + } + } + + createGameDirectory() { + if (this.options.overrides.gameDirectory) { + this.options.overrides.gameDirectory = path.resolve(this.options.overrides.gameDirectory) + if (!fs.existsSync(this.options.overrides.gameDirectory)) { + fs.mkdirSync(this.options.overrides.gameDirectory, { recursive: true }) + } + } + } + + async extractPackage() { + if (this.options.clientPackage) { + this.emit('debug', `[MCLC]: Extracting client package to ${this.options.root}`) + await this.handler.extractPackage() + } + } + + async getModifyJson() { + let modifyJson = null + + if (this.options.forge) { + this.options.forge = path.resolve(this.options.forge) + this.emit('debug', '[MCLC]: Detected Forge in options, getting dependencies') + modifyJson = await this.handler.getForgedWrapped() + } else if (this.options.version.custom) { + this.emit('debug', '[MCLC]: Detected custom in options, setting custom version file') + modifyJson = modifyJson || JSON.parse(fs.readFileSync(path.join(this.options.root, 'versions', this.options.version.custom, `${this.options.version.custom}.json`), { encoding: 'utf8' })) + } + + return modifyJson + } + + startMinecraft(launchArguments) { + const minecraft = child.spawn(this.options.javaPath ? this.options.javaPath : 'java', launchArguments, + { cwd: this.options.overrides.cwd || this.options.root, detached: this.options.overrides.detached }) + minecraft.stdout.on('data', (data) => this.emit('data', data.toString('utf-8'))) + minecraft.stderr.on('data', (data) => this.emit('data', data.toString('utf-8'))) + minecraft.on('close', (code) => this.emit('close', code)) + return minecraft + } +} \ No newline at end of file