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; 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", `[PiMi]: 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", `[PiMi]: 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", `[PiMi]: 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", `[PiMi]: 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", `[PiMi]: 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", "[PiMi]: Cache directory created."); } fs.writeFile( path.join(`${cache}/version_manifest.json`), body, (err) => { if (err) return resolve(err); this.client.emit("debug", "[PiMi]: Cached version_manifest.json"); } ); } let parsed; if (error && error.code === "ENOTFOUND") { parsed = JSON.parse( fs.readFileSync(`${cache}/version_manifest.json`) ); } else { parsed = JSON.parse(body); } for (const desiredVersion in parsed.versions) { if ( parsed.versions[desiredVersion].id === this.options.version.number ) { request.get( parsed.versions[desiredVersion].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", `[PiMi]: Cached ${this.options.version.number}.json` ); } ); } this.client.emit( "debug", "[PiMi]: 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); } 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", "[PiMi]: 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", "[PiMi]: 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", `[PiMi]: 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", "[PiMi]: 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", "[PiMi]: Downloaded and extracted natives"); } counter = 0; this.client.emit("debug", `[PiMi]: 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 PiMi, 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", "[PiMi]: Old ForgeWrapper has generated this version JSON, re-generating" ); } else { // If forge is modern, add ForgeWrappers launch arguments and set forge to null so PiMi 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", "[PiMi]: Failed to parse Forge version JSON, re-generating" ); } } this.client.emit( "debug", "[PiMi]: 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", "[PiMi]: Failed to load json files for ForgeWrapper, using Vanilla instead" ); return null; } // Adding the installer libraries as mavenFiles so PiMi 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 PiMi 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 PiMi'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", `[PiMi]: 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 PiMi 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", "[PiMi]: 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", `[PiMi]: 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 = modification || this.version; 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", "[PiMi]: 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", "[PiMi]: 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", "[PiMi]: 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", "[PiMi]: 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); } } module.exports = Handler;