From cb62004107b387780cbfcdea7fe72357c82329f1 Mon Sep 17 00:00:00 2001 From: Shadowner Date: Thu, 9 Mar 2023 01:53:11 +0100 Subject: [PATCH] Even more Refactoring Diving a bit in some http request and startic to replace old promise style + request package by node-fetch --- package.json | 5 + src/MicrosoftType.ts | 29 + src/dto/Minecraft.ts | 98 ++++ src/lang/en_US.json | 49 ++ src/main.ts | 343 +++++++++++ src/manager/ConfigManager.ts | 6 +- src/manager/DistroManager.ts | 77 +-- src/models/Artifact.ts | 17 +- src/models/Asset.ts | 13 +- src/models/DLTracker.ts | 1 - src/models/DistroAsset.ts | 9 +- src/models/DistroIndex.ts | 6 +- src/models/Module.ts | 40 +- src/models/Required.ts | 2 +- src/scripts/LangLoader.ts | 26 + src/scripts/Preloading.ts | 70 +++ src/scripts/views/welcome.ts | 9 + src/services/AssetGuard.ts | 873 ++++++++++++++++++++++++++++ src/services/DiscordRichPresence.ts | 62 ++ src/services/JavaGuard.ts | 724 +++++++++++++++++++++++ src/services/ProcessBuilder.ts | 200 +++---- src/util/DevUtil.ts | 18 + src/util/JavaType.ts | 49 ++ src/util/MinecraftUtil.ts | 9 +- 24 files changed, 2552 insertions(+), 183 deletions(-) create mode 100644 src/MicrosoftType.ts create mode 100644 src/dto/Minecraft.ts create mode 100644 src/lang/en_US.json create mode 100644 src/scripts/LangLoader.ts create mode 100644 src/scripts/Preloading.ts create mode 100644 src/scripts/views/welcome.ts create mode 100644 src/services/AssetGuard.ts create mode 100644 src/services/DiscordRichPresence.ts create mode 100644 src/util/DevUtil.ts create mode 100644 src/util/JavaType.ts diff --git a/package.json b/package.json index 3c89b2e1..830aef73 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,10 @@ }, "dependencies": { "@electron/remote": "^2.0.8", + "@types/adm-zip": "^0.5.0", + "@types/ejs": "^3.1.2", + "@types/node": "^18.14.6", + "@types/request": "^2.48.8", "adm-zip": "^0.5.9", "async": "^3.2.4", "discord-rpc-patch": "^4.0.1", @@ -36,6 +40,7 @@ "helios-core": "~0.1.2", "jquery": "^3.6.1", "node-disk-info": "^1.3.0", + "node-fetch": "^3.3.0", "node-stream-zip": "^1.15.0", "request": "^2.88.2", "semver": "^7.3.8", diff --git a/src/MicrosoftType.ts b/src/MicrosoftType.ts new file mode 100644 index 00000000..ce06ad7b --- /dev/null +++ b/src/MicrosoftType.ts @@ -0,0 +1,29 @@ +import { ConfigManager } from './manager/ConfigManager'; +// NOTE FOR THIRD-PARTY +// REPLACE THIS CLIENT ID WITH YOUR APPLICATION ID. +// SEE https://github.com/dscalzi/HeliosLauncher/blob/master/docs/MicrosoftAuth.md +exports.AZURE_CLIENT_ID = ConfigManager.azureClientId; +// SEE NOTE ABOVE. + + +// Opcodes +export enum MSFT_OPCODE { + OPEN_LOGIN = 'MSFT_AUTH_OPEN_LOGIN', + OPEN_LOGOUT = 'MSFT_AUTH_OPEN_LOGOUT', + REPLY_LOGIN = 'MSFT_AUTH_REPLY_LOGIN', + REPLY_LOGOUT = 'MSFT_AUTH_REPLY_LOGOUT' +} +// Reply types for REPLY opcode. +export enum MSFT_REPLY_TYPE { + SUCCESS = 'MSFT_AUTH_REPLY_SUCCESS', + ERROR = 'MSFT_AUTH_REPLY_ERROR' +} +// Error types for ERROR reply. +export enum MSFT_ERROR { + ALREADY_OPEN = 'MSFT_AUTH_ERR_ALREADY_OPEN', + NOT_FINISHED = 'MSFT_AUTH_ERR_NOT_FINISHED' +} + +export enum SHELL_OPCODE { + TRASH_ITEM = 'TRASH_ITEM' +} \ No newline at end of file diff --git a/src/dto/Minecraft.ts b/src/dto/Minecraft.ts new file mode 100644 index 00000000..c172238f --- /dev/null +++ b/src/dto/Minecraft.ts @@ -0,0 +1,98 @@ +export type MinecraftGameManifest = { + latest: { + release: string, + snapshot: string + }, + versions: { + id: string, + type: string, + url: string, + time: string, + releaseTime: string + }[] +} + +export type MinecraftGameVersionManifest = { + arguments: { + game: (ArgumentRule | string)[], + jvm: (ArgumentRule | string)[] + }, + minimumLauncherVersion: number, + releaseTime: Date, + time: Date, + //Could be more precise I think + type: string + assets: string, + complianceLevel: number, + id: string, + + assetIndex: + { + id: string, + sha1: string, + size: number, + totalSize: number, + url: string + }, + + downloads: + { + client: MinecraftFileInfo + client_mappings: MinecraftFileInfo + server: MinecraftFileInfo, + server_mappings: MinecraftFileInfo + }, + javaVersion: { component: string, majorVersion: number }, + logging: { + client: + { + argument: string, + file: MinecraftFileInfo, + type: string + } + }, + mainClass: string, + libraries: MinecraftLibrairie[] +} + +export type MinecraftLibrairie = { + downloads: { + artifact: MinecraftLibrairieFile, + classifiers: Record + }, + extract?: { + exclude: string[] + }, + name: string, + natives: Record, + rules: MinecraftRule +} + +export type MinecraftLibrairieFile = Omit & { + path: string, +} + +export type MinecraftFileInfo = { + id?: string, + sha1: string, + size: number, + url: string, +} + +export type MinecraftRule = { + action: string, + features: Record + os: { + name: string, + version?: string + } +} + +export type ArgumentRule = { + rules: MinecraftRule[], + value: string[], +} + +export type MinecraftAssetJson = { + objects: Record +} \ No newline at end of file diff --git a/src/lang/en_US.json b/src/lang/en_US.json new file mode 100644 index 00000000..25b34c24 --- /dev/null +++ b/src/lang/en_US.json @@ -0,0 +1,49 @@ +{ + "html": { + "avatarOverlay": "Edit" + }, + "js": { + "login": { + "error": { + "invalidValue": "* Invalid Value", + "requiredValue": "* Required", + "userMigrated": { + "title": "Error During Login:
Invalid Credentials", + "desc": "You've attempted to login with a migrated account. Try again using the account email as the username." + }, + "invalidCredentials": { + "title": "Error During Login:
Invalid Credentials", + "desc": "The email or password you've entered is incorrect. Please try again." + }, + "rateLimit": { + "title": "Error During Login:
Too Many Attempts", + "desc": "There have been too many login attempts with this account recently. Please try again later." + }, + "noInternet": { + "title": "Error During Login:
No Internet Connection", + "desc": "You must be connected to the internet in order to login. Please connect and try again." + }, + "authDown": { + "title": "Error During Login:
Authentication Server Offline", + "desc": "Mojang's authentication server is currently offline or unreachable. Please wait a bit and try again. You can check the status of the server on Mojang's help portal." + }, + "notPaid": { + "title": "Error During Login:
Game Not Purchased", + "desc": "The account you are trying to login with has not purchased a copy of Minecraft.
You may purchase a copy on Minecraft.net" + }, + "unknown": { + "title": "Error During Login:
Unknown Error" + } + }, + "login": "LOGIN", + "loggingIn": "LOGGING IN", + "success": "SUCCESS", + "tryAgain": "Try Again" + }, + "landing": { + "launch": { + "pleaseWait": "Please wait.." + } + } + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index e69de29b..27a33b03 100644 --- a/src/main.ts +++ b/src/main.ts @@ -0,0 +1,343 @@ +import remoteMain from "@electron/remote/main"; +import { app, ipcMain, Menu, MenuItem, shell } from "electron"; +import { autoUpdater } from "electron-updater"; +import { join } from "path"; +import { prerelease } from "semver"; +import { DevUtil } from "./util/DevUtil"; +import { MSFT_ERROR, MSFT_OPCODE, MSFT_REPLY_TYPE, SHELL_OPCODE } from './MicrosoftType'; +import { BrowserWindow } from "@electron/remote"; +import { readdirSync } from "fs-extra"; +import { pathToFileURL } from "url"; +import { data } from "ejs-electron"; +import { ConfigManager } from "./manager/ConfigManager"; + +remoteMain.initialize(); +// Setup auto updater. +function initAutoUpdater(event, data) { + + if (data) { + autoUpdater.allowPrerelease = true + } else { + // Defaults to true if application version contains prerelease components (e.g. 0.12.1-alpha.1) + // autoUpdater.allowPrerelease = true + } + + if (DevUtil.IsDev) { + autoUpdater.autoInstallOnAppQuit = false + autoUpdater.updateConfigPath = join(__dirname, 'dev-app-update.yml') + } + if (process.platform === 'darwin') { + autoUpdater.autoDownload = false + } + autoUpdater.on('update-available', (info) => { + event.sender.send('autoUpdateNotification', 'update-available', info) + }) + autoUpdater.on('update-downloaded', (info) => { + event.sender.send('autoUpdateNotification', 'update-downloaded', info) + }) + autoUpdater.on('update-not-available', (info) => { + event.sender.send('autoUpdateNotification', 'update-not-available', info) + }) + autoUpdater.on('checking-for-update', () => { + event.sender.send('autoUpdateNotification', 'checking-for-update') + }) + autoUpdater.on('error', (err) => { + event.sender.send('autoUpdateNotification', 'realerror', err) + }) +} + +// Open channel to listen for update actions. +ipcMain.on('autoUpdateAction', (event, arg, data) => { + switch (arg) { + case 'initAutoUpdater': + console.log('Initializing auto updater.') + initAutoUpdater(event, data) + event.sender.send('autoUpdateNotification', 'ready') + break + case 'checkForUpdate': + autoUpdater.checkForUpdates() + .catch(err => { + event.sender.send('autoUpdateNotification', 'realerror', err) + }) + break + case 'allowPrereleaseChange': + if (!data) { + const preRelComp = prerelease(app.getVersion()) + if (preRelComp != null && preRelComp.length > 0) { + autoUpdater.allowPrerelease = true + } else { + autoUpdater.allowPrerelease = data + } + } else { + autoUpdater.allowPrerelease = data + } + break + case 'installUpdateNow': + autoUpdater.quitAndInstall() + break + default: + console.log('Unknown argument', arg) + break + } +}); + +// Redirect distribution index event from preloader to renderer. +ipcMain.on('distributionIndexDone', (event, res) => { + event.sender.send('distributionIndexDone', res) +}) + +// Handle trash item. +ipcMain.handle(SHELL_OPCODE.TRASH_ITEM, async (event, ...args) => { + try { + await shell.trashItem(args[0]) + return { + result: true + } + } catch (error) { + return { + result: false, + error: error + } + } +}) + + +const REDIRECT_URI_PREFIX = 'https://login.microsoftonline.com/common/oauth2/nativeclient?' + +// Microsoft Auth Login +let msftAuthWindow +let msftAuthSuccess +let msftAuthViewSuccess +let msftAuthViewOnClose +ipcMain.on(MSFT_OPCODE.OPEN_LOGIN, (ipcEvent, ...arguments_) => { + if (msftAuthWindow) { + ipcEvent.reply(MSFT_OPCODE.REPLY_LOGIN, MSFT_REPLY_TYPE.ERROR, MSFT_ERROR.ALREADY_OPEN, msftAuthViewOnClose) + return + } + msftAuthSuccess = false + msftAuthViewSuccess = arguments_[0] + msftAuthViewOnClose = arguments_[1] + msftAuthWindow = new BrowserWindow({ + title: 'Microsoft Login', + backgroundColor: '#222222', + width: 520, + height: 600, + frame: true, + icon: getPlatformIcon('SealCircle') + }) + + msftAuthWindow.on('closed', () => { + msftAuthWindow = undefined + }) + + msftAuthWindow.on('close', () => { + if (!msftAuthSuccess) { + ipcEvent.reply(MSFT_OPCODE.REPLY_LOGIN, MSFT_REPLY_TYPE.ERROR, MSFT_ERROR.NOT_FINISHED, msftAuthViewOnClose) + } + }) + + msftAuthWindow.webContents.on('did-navigate', (_, uri) => { + if (uri.startsWith(REDIRECT_URI_PREFIX)) { + let queries = uri.substring(REDIRECT_URI_PREFIX.length).split('#', 1).toString().split('&') + let queryMap = {} + + queries.forEach(query => { + const [name, value] = query.split('=') + queryMap[name] = decodeURI(value) + }) + + ipcEvent.reply(MSFT_OPCODE.REPLY_LOGIN, MSFT_REPLY_TYPE.SUCCESS, queryMap, msftAuthViewSuccess) + + msftAuthSuccess = true + msftAuthWindow.close() + msftAuthWindow = null + } + }) + + msftAuthWindow.removeMenu() + msftAuthWindow.loadURL(`https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?prompt=select_account&client_id=${ConfigManager.azureClientId}&response_type=code&scope=XboxLive.signin%20offline_access&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient`) +}) + +// Microsoft Auth Logout +let msftLogoutWindow +let msftLogoutSuccess +let msftLogoutSuccessSent +ipcMain.on(MSFT_OPCODE.OPEN_LOGOUT, (ipcEvent, uuid, isLastAccount) => { + if (msftLogoutWindow) { + ipcEvent.reply(MSFT_OPCODE.REPLY_LOGOUT, MSFT_REPLY_TYPE.ERROR, MSFT_ERROR.ALREADY_OPEN) + return + } + + msftLogoutSuccess = false + msftLogoutSuccessSent = false + msftLogoutWindow = new BrowserWindow({ + title: 'Microsoft Logout', + backgroundColor: '#222222', + width: 520, + height: 600, + frame: true, + icon: getPlatformIcon('SealCircle') + }) + + msftLogoutWindow.on('closed', () => { + msftLogoutWindow = undefined + }) + + msftLogoutWindow.on('close', () => { + if (!msftLogoutSuccess) { + ipcEvent.reply(MSFT_OPCODE.REPLY_LOGOUT, MSFT_REPLY_TYPE.ERROR, MSFT_ERROR.NOT_FINISHED) + } else if (!msftLogoutSuccessSent) { + msftLogoutSuccessSent = true + ipcEvent.reply(MSFT_OPCODE.REPLY_LOGOUT, MSFT_REPLY_TYPE.SUCCESS, uuid, isLastAccount) + } + }) + + msftLogoutWindow.webContents.on('did-navigate', (_, uri) => { + if (uri.startsWith('https://login.microsoftonline.com/common/oauth2/v2.0/logoutsession')) { + msftLogoutSuccess = true + setTimeout(() => { + if (!msftLogoutSuccessSent) { + msftLogoutSuccessSent = true + ipcEvent.reply(MSFT_OPCODE.REPLY_LOGOUT, MSFT_REPLY_TYPE.SUCCESS, uuid, isLastAccount) + } + + if (msftLogoutWindow) { + msftLogoutWindow.close() + msftLogoutWindow = null + } + }, 5000) + } + }) + + msftLogoutWindow.removeMenu() + msftLogoutWindow.loadURL('https://login.microsoftonline.com/common/oauth2/v2.0/logout') +}) + + +// Keep a global reference of the window object, if you don't, the window will +// be closed automatically when the JavaScript object is garbage collected. +let win + +function createWindow() { + + win = new BrowserWindow({ + width: 980, + height: 552, + icon: getPlatformIcon('SealCircle'), + frame: false, + webPreferences: { + preload: join(__dirname, 'app', 'assets', 'js', 'preloader.js'), + nodeIntegration: true, + contextIsolation: false + }, + backgroundColor: '#171614' + }) + remoteMain.enable(win.webContents) + + data('bkid', Math.floor((Math.random() * readdirSync(join(__dirname, 'app', 'assets', 'images', 'backgrounds')).length))) + + win.loadURL(pathToFileURL(join(__dirname, 'app', 'app.ejs')).toString()) + + /*win.once('ready-to-show', () => { + win.show() + })*/ + + win.removeMenu() + + win.resizable = true + + win.on('closed', () => { + win = null + }) +} + +function createMenu() { + if (process.platform === 'darwin') { + // Extend default included application menu to continue support for quit keyboard shortcut + let applicationSubMenu = new MenuItem({ + label: 'Application', + submenu: [{ + label: 'About Application', + }, { + type: 'separator' + }, { + label: 'Quit', + accelerator: 'Command+Q', + click: () => { + app.quit() + } + }] + }) + + // New edit menu adds support for text-editing keyboard shortcuts + let editSubMenu = new MenuItem({ + label: "Edit", + submenu: [{ + label: 'Undo', + accelerator: 'CmdOrCtrl+Z', + }, { + label: 'Redo', + accelerator: 'Shift+CmdOrCtrl+Z', + }, { + type: 'separator' + }, { + label: 'Cut', + accelerator: 'CmdOrCtrl+X', + }, { + label: 'Copy', + accelerator: 'CmdOrCtrl+C', + }, { + label: 'Paste', + accelerator: 'CmdOrCtrl+V', + }, { + label: 'Select All', + accelerator: 'CmdOrCtrl+A', + }] + }) + + // Bundle submenus into a single template and build a menu object with it + + let menuTemplate = [applicationSubMenu, editSubMenu] + let menuObject = Menu.buildFromTemplate(menuTemplate) + + // Assign it to the application + Menu.setApplicationMenu(menuObject) + + } + +} + +function getPlatformIcon(filename) { + let ext + switch (process.platform) { + case 'win32': + ext = 'ico' + break + case 'darwin': + case 'linux': + default: + ext = 'png' + break + } + + return join(__dirname, 'app', 'assets', 'images', `${filename}.${ext}`) +} + +app.on('ready', createWindow) +app.on('ready', createMenu) + +app.on('window-all-closed', () => { + // On macOS it is common for applications and their menu bar + // to stay active until the user quits explicitly with Cmd + Q + if (process.platform !== 'darwin') { + app.quit() + } +}) + +app.on('activate', () => { + // On macOS it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (win === null) { + createWindow() + } +}) \ No newline at end of file diff --git a/src/manager/ConfigManager.ts b/src/manager/ConfigManager.ts index 5023bf82..71f7996f 100644 --- a/src/manager/ConfigManager.ts +++ b/src/manager/ConfigManager.ts @@ -3,7 +3,7 @@ import { LoggerUtil } from "helios-core/."; import { existsSync, readFileSync, writeFileSync } from "fs-extra"; import os from 'os'; import { join } from 'path'; -import { mcVersionAtLeast } from "../util/MinecraftUtil"; +import { MinecraftUtil } from "../util/MinecraftUtil"; import { resolveMaxRAM, resolveMinRAM } from "../util/System"; const logger = LoggerUtil.getLogger("ConfigManager"); @@ -49,7 +49,7 @@ export class ConfigManager { private static launcherDir = process.env.CONFIG_DIRECT_PATH ?? require("@electron/remote").app.getPath("userData"); public static readonly DistributionURL = 'http://mc.westeroscraft.com/WesterosCraftLauncher/distribution.json'; public static readonly launcherName = 'Helios-Launcher' - + public static readonly azureClientId = '1ce6e35a-126f-48fd-97fb-54d143ac6d45' /** * Three types of values: * Static = Explicitly declared. @@ -771,7 +771,7 @@ export class ConfigManager { }; private static defaultJavaConfig(mcVersion: string) { - if (mcVersionAtLeast("1.17", mcVersion)) { + if (MinecraftUtil.mcVersionAtLeast("1.17", mcVersion)) { return this.defaultJavaConfig117(); } else { return this.defaultJavaConfigBelow117(); diff --git a/src/manager/DistroManager.ts b/src/manager/DistroManager.ts index e6d53148..baaed737 100644 --- a/src/manager/DistroManager.ts +++ b/src/manager/DistroManager.ts @@ -1,10 +1,10 @@ import { readFile, writeFile } from "fs-extra" import { LoggerUtil } from "helios-core/."; -import { DevUtil } from '../util/isDev'; -import request from "request"; +import { DevUtil } from '../util/DevUtil'; import { ConfigManager } from "./ConfigManager"; import { join } from 'path'; -import { DistroIndex } from '../models/DistroIndex'; +import { DistroIndex, IDistroIndex } from '../models/DistroIndex'; +import fetch from 'node-fetch'; const logger = LoggerUtil.getLogger('DistroManager') export enum DistroTypes { @@ -20,69 +20,40 @@ export enum DistroTypes { export class DistroManager { - public distribution!: DistroIndex; - private readonly DISTRO_PATH = join(ConfigManager.getLauncherDirectory(), 'distribution.json') - private readonly DEV_PATH = join(ConfigManager.getLauncherDirectory(), 'dev_distribution.json') + public static distribution?: DistroIndex; + private static readonly DISTRO_PATH = join(ConfigManager.getLauncherDirectory(), 'distribution.json') + private static readonly DEV_PATH = join(ConfigManager.getLauncherDirectory(), 'dev_distribution.json') /** * @returns {Promise.} */ - public pullRemote() { - if (DevUtil.IsDev) { - return exports.pullLocal() - } - return new Promise((resolve, reject) => { - const opts = { - url: ConfigManager.DistributionURL, - timeout: 2500 - } - const distroDest = join(ConfigManager.getLauncherDirectory(), 'distribution.json') - request(opts, (error: Error, _resp: any, body: string) => { - if (!error) { + public static async pullRemote() { + if (DevUtil.IsDev) return this.pullLocal(); - try { - this.distribution = DistroIndex.fromJSON(JSON.parse(body)) - } catch (e) { - reject(e) - return - } + const distroDest = join(ConfigManager.getLauncherDirectory(), 'distribution.json') + const response = await fetch(ConfigManager.DistributionURL, { signal: AbortSignal.timeout(2500) }); - writeFile(distroDest, body, 'utf-8', (err) => { - if (!err) { - resolve(this.distribution) - return - } else { - reject(err) - return - } - }) - } else { - reject(error) - return - } - }) - }) + this.distribution = DistroIndex.fromJSON(await response.json() as IDistroIndex); + + writeFile(distroDest, JSON.stringify(this.distribution), 'utf-8').catch(e => { + logger.warn("Failed to save local distribution.json") + logger.warn(e); + }); + + return this.distribution; } /** * @returns {Promise.} */ - public pullLocal() { - return new Promise((resolve, reject) => { - readFile(DevUtil.IsDev ? this.DEV_PATH : this.DISTRO_PATH, 'utf-8', (err, d) => { - if (!err) { - this.distribution = DistroIndex.fromJSON(JSON.parse(d)) - resolve(this.distribution) - return - } else { - reject(err) - return - } - }) - }) + public static async pullLocal() { + const file = await readFile(DevUtil.IsDev ? this.DEV_PATH : this.DISTRO_PATH, 'utf-8'); + this.distribution = DistroIndex.fromJSON(JSON.parse(file)); + return this.distribution; + } - public setDevMode(value: boolean) { + public static setDevMode(value: boolean) { if (value) { logger.info('Developer mode enabled.') logger.info('If you don\'t know what that means, revert immediately.') diff --git a/src/models/Artifact.ts b/src/models/Artifact.ts index 9b62a2f0..abb6b976 100644 --- a/src/models/Artifact.ts +++ b/src/models/Artifact.ts @@ -2,6 +2,14 @@ * Represents the download information * for a specific module. */ + +export interface IArtifact { + MD5: string, + size: string, + url: string, + path: string, +} + export class Artifact { /** @@ -11,12 +19,7 @@ export class Artifact { * * @returns {Artifact} The parsed Artifact. */ - public static fromJSON(json: { - MD5: string, - size: string, - url: string, - path: string, - }) { + public static fromJSON(json: IArtifact) { return new Artifact(json.MD5, json.size, json.url, json.path) } @@ -27,6 +30,8 @@ export class Artifact { public path: string, ) { } + //TODO: Remove those property + /** * Get the MD5 hash of the artifact. This value may * be undefined for artifacts which are not to be diff --git a/src/models/Asset.ts b/src/models/Asset.ts index 04f2952d..761d300b 100644 --- a/src/models/Asset.ts +++ b/src/models/Asset.ts @@ -1,16 +1,25 @@ /** Class representing a base asset. */ + +export interface IAsset { + id: string, + hash: string, + size: number, + from: string, + to: string +} + export class Asset { /** * Create an asset. * - * @param {any} id The id of the asset. + * @param {string} 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. */ constructor( - public id: any, + public id: string, public hash: string, public size: number, public from: string, diff --git a/src/models/DLTracker.ts b/src/models/DLTracker.ts index 03dfdd21..5e51fbd9 100644 --- a/src/models/DLTracker.ts +++ b/src/models/DLTracker.ts @@ -16,7 +16,6 @@ export class DLTracker { public dlqueue: Asset[], public dlsize: number, public callback?: (asset: Asset) => void) { - } } \ No newline at end of file diff --git a/src/models/DistroAsset.ts b/src/models/DistroAsset.ts index 283fe720..9d215197 100644 --- a/src/models/DistroAsset.ts +++ b/src/models/DistroAsset.ts @@ -1,4 +1,5 @@ import { Asset } from "./Asset" +import { DistroTypes } from '../manager/DistroManager'; export class DistroAsset extends Asset { @@ -14,8 +15,12 @@ export class DistroAsset extends Asset { * @param {string} to The absolute local file path of the asset. * @param {string} type The the module type. */ - constructor(id: any, hash: string, size: number, from: string, to: string, - public type + constructor(public id: any, + hash: string, + size: number, + from: string, + to: string, + public type: DistroTypes ) { super(id, hash, size, from, to) this.type = type diff --git a/src/models/DistroIndex.ts b/src/models/DistroIndex.ts index 895c1ad0..541d8c31 100644 --- a/src/models/DistroIndex.ts +++ b/src/models/DistroIndex.ts @@ -1,6 +1,6 @@ import { IServer, Server } from './Server'; -interface IDistroIndex { +export interface IDistroIndex { version: string, rss: string, servers: IServer[] @@ -36,6 +36,10 @@ export class DistroIndex { this.resolveServers(servers); } + public getServer(id: string) { + return this.servers.find(server => server.id === id); + } + private resolveServers(serverJsons: IServer[]) { const servers: Server[] = [] for (let serverJson of serverJsons) { diff --git a/src/models/Module.ts b/src/models/Module.ts index b76c0587..f7ed7c6f 100644 --- a/src/models/Module.ts +++ b/src/models/Module.ts @@ -1,11 +1,27 @@ import { LoggerUtil } from 'helios-core/.'; import { ConfigManager } from '../manager/ConfigManager'; -import { Artifact } from './Artifact'; +import { Artifact, IArtifact } from './Artifact'; import { join } from 'path'; import { DistroTypes } from '../manager/DistroManager'; +import { Required, IRequired } from './Required'; const logger = LoggerUtil.getLogger('Module') +export interface IModule { + artifactExt: string; + artifactClassifier?: string; + artifactVersion: string; + artifactID: string; + artifactGroup: string; + subModules: IModule[]; + required: IRequired; + artifact: IArtifact; + id: string, + name: string, + type: DistroTypes, + classpath: boolean +} + export class Module { /** @@ -16,7 +32,7 @@ export class Module { * * @returns {Module} The parsed Module. */ - public static fromJSON(json, serverid) { + public static fromJSON(json: IModule, serverid: string) { return new Module(json.id, json.name, json.type, json.classpath, json.required, json.artifact, json.subModules, serverid) } @@ -50,7 +66,8 @@ export class Module { public artifactGroup: string; public subModules: Module[] = [] - + public required: Required; + public artifact: Artifact /** * @returns {string} The identifier without he version or extension. */ @@ -70,17 +87,22 @@ export class Module { } - constructor(public identifier: string, + + constructor( + public identifier: string, public name: string, public type: DistroTypes, public classpath: boolean = true, - public required = Required.fromJSON(required), - public artifact = Artifact.fromJSON(artifact), - subModules, - serverid + required: IRequired, + artifact: IArtifact, + subModules: IModule[], + serverid: string ) { + this.required = Required.fromJSON(required); + this.artifact = Artifact.fromJSON(artifact); + this.resolveMetaData() - this.resolveArtifactPath(artifact.path, serverid) + this.resolveArtifactPath(this.artifact.path, serverid) this.resolveSubModules(subModules, serverid) } diff --git a/src/models/Required.ts b/src/models/Required.ts index ce589e17..97919691 100644 --- a/src/models/Required.ts +++ b/src/models/Required.ts @@ -17,7 +17,7 @@ export class Required { * * @returns {Required} The parsed Required object. */ - static fromJSON(json: IRequired) { + public static fromJSON(json: IRequired) { if (json == null) { return new Required(true, true) } else { diff --git a/src/scripts/LangLoader.ts b/src/scripts/LangLoader.ts new file mode 100644 index 00000000..c4362dc9 --- /dev/null +++ b/src/scripts/LangLoader.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'fs-extra'; +import { join } from 'path'; +import type LangType from "../lang/en_US.json"; + +export class LangLoader { + public static lang?: typeof LangType; + + public static loadLanguage(langId: string) { + this.lang = readFileSync(join(__dirname, '..', 'lang', `${langId}.json`)).toJSON() as any || undefined; + } + + public static query(langId: string) { + if (!this.lang) return ""; + + let query = langId.split('.') + let res = this.lang + for (let q of query) { + res = res[q] + } + return res === this.lang ? {} : res + } + + public static queryJS(id) { + return this.query(`js.${id}`) + } +} \ No newline at end of file diff --git a/src/scripts/Preloading.ts b/src/scripts/Preloading.ts new file mode 100644 index 00000000..06fd157a --- /dev/null +++ b/src/scripts/Preloading.ts @@ -0,0 +1,70 @@ +import { ipcRenderer } from "electron"; +import { remove } from "fs-extra"; +import { LoggerUtil } from "helios-core/."; +import { ConfigManager } from "../manager/ConfigManager"; +import { DistroManager } from "../manager/DistroManager"; +import { join } from 'path'; +import os from 'os'; +import { LangLoader } from "./LangLoader"; + +const logger = LoggerUtil.getLogger('Preloader') +logger.info('Loading..') + + +// Load ConfigManager +ConfigManager.load() + +// Load Strings +LangLoader.loadLanguage('en_US') + +function onDistroLoad(data) { + if (data != null) { + + // Resolve the selected server if its value has yet to be set. + if (ConfigManager.getSelectedServer() == null || data.getServer(ConfigManager.getSelectedServer()) == null) { + logger.info('Determining default selected server..') + ConfigManager.selectedServer = data.getMainServer().getID(); + ConfigManager.save() + } + } + ipcRenderer.send('distributionIndexDone', data != null) +} + +// Ensure Distribution is downloaded and cached. +DistroManager.pullRemote().then((data) => { + logger.info('Loaded distribution index.') + + onDistroLoad(data) + +}).catch((err) => { + logger.info('Failed to load distribution index.') + logger.error(err) + + logger.info('Attempting to load an older version of the distribution index.') + // Try getting a local copy, better than nothing. + DistroManager.pullLocal().then((data) => { + logger.info('Successfully loaded an older version of the distribution index.') + + onDistroLoad(data) + + + }).catch((err) => { + + logger.info('Failed to load an older version of the distribution index.') + logger.info('Application cannot run.') + logger.error(err) + + onDistroLoad(null) + + }) + +}) + +// Clean up temp dir incase previous launches ended unexpectedly. +remove(join(os.tmpdir(), ConfigManager.tempNativeFolder), (err) => { + if (err) { + logger.warn('Error while cleaning natives directory', err) + } else { + logger.info('Cleaned natives directory.') + } +}) \ No newline at end of file diff --git a/src/scripts/views/welcome.ts b/src/scripts/views/welcome.ts new file mode 100644 index 00000000..d2bd7cc5 --- /dev/null +++ b/src/scripts/views/welcome.ts @@ -0,0 +1,9 @@ +/** + * Script for welcome.ejs + */ +document.getElementById('welcomeButton')?.addEventListener('click', e => { + loginOptionsCancelEnabled(false) // False by default, be explicit. + loginOptionsViewOnLoginSuccess = VIEWS.landing + loginOptionsViewOnLoginCancel = VIEWS.loginOptions + switchView(VIEWS.welcome, VIEWS.loginOptions) +}) \ No newline at end of file diff --git a/src/services/AssetGuard.ts b/src/services/AssetGuard.ts new file mode 100644 index 00000000..91d089d0 --- /dev/null +++ b/src/services/AssetGuard.ts @@ -0,0 +1,873 @@ +import { LoggerUtil } from "helios-core/."; +import * as EventEmitter from 'events'; +import { DLTracker } from '../models/DLTracker'; +import { createHash } from "crypto"; +import { existsSync, readFileSync, readFile, ensureDirSync, writeFileSync, createWriteStream, unlink, createReadStream, remove, ensureDir, writeFile, pathExists, pathExistsSync } from 'fs-extra'; +import AdmZip from 'adm-zip'; +import { DevUtil } from '../util/DevUtil'; +import { join } from 'path'; +import { spawn } from 'child_process'; +import request from 'request'; +import asyncModule from "async"; +import { Asset } from "../models/Asset"; +import { Library } from '../models/Library'; +import { DistroAsset } from '../models/DistroAsset'; +import { Module } from '../models/Module'; +import { Artifact } from '../models/Artifact'; +import { Server } from '../models/Server'; +import { DistroManager, DistroTypes } from '../manager/DistroManager'; +import { MinecraftUtil } from "../util/MinecraftUtil"; +import { createGunzip } from "zlib"; +import { extract } from "tar-fs"; +import { JavaGuard } from './JavaGuard'; +import { StreamZipAsync } from "node-stream-zip"; +import { ConfigManager } from "../manager/ConfigManager"; +import fetch from 'node-fetch'; +import { MinecraftGameManifest, MinecraftGameVersionManifest, MinecraftAssetJson } from '../dto/Minecraft'; +const logger = LoggerUtil.getLogger('AssetGuard'); + +export class AssetGuard extends EventEmitter { + + + // Static Utility Functions + // #region + + // Static Hash Validation Functions + // #region + + /** + * Calculates the hash for a file using the specified algorithm. + * + * @param {Buffer} buffer The buffer containing file data. + * @param {string} algo The hash algorithm. + * @returns {string} The calculated hash in hex. + */ + private static calculateHash(buffer: Buffer, algo: string): string { + return createHash(algo).update(buffer).digest('hex') + } + + /** + * Used to parse a checksums file. This is specifically designed for + * the checksums.sha1 files found inside the forge scala dependencies. + * + * @param {string} content The string content of the checksums file. + * @returns { Record} An object with keys being the file names, and values being the hashes. + */ + private static parseChecksumsFile(content: string): Record { + let finalContent: Record = {} + let lines = content.split('\n') + for (let i = 0; i < lines.length; i++) { + let bits = lines[i].split(' ') + if (bits[1] == null) { + continue + } + finalContent[bits[1]] = bits[0] + } + return finalContent + } + + /** + * Validate that a file exists and matches a given hash value. + * + * @param {string} filePath The path of the file to validate. + * @param {string} algo The hash algorithm to check against. + * @param {string} hash The existing hash to check against. + * @returns {boolean} True if the file exists and calculated hash matches the given hash, otherwise false. + */ + private static validateLocal(filePath: string, algo: string, hash: string): boolean { + if (existsSync(filePath)) { + //No hash provided, have to assume it's good. + if (hash == null) { + return true + } + let buf = readFileSync(filePath) + let calcdhash = AssetGuard.calculateHash(buf, algo) + return calcdhash === hash.toLowerCase() + } + return false + } + + + /** + * Validates a file in the style used by forge's version index. + * + * @param {string} filePath The path of the file to validate. + * @param {Array.} checksums The checksums listed in the forge version index. + * @returns {boolean} True if the file exists and the hashes match, otherwise false. + */ + private static validateForgeChecksum(filePath: string, checksums: string[]): boolean { + if (existsSync(filePath)) { + if (checksums == null || checksums.length === 0) { + return true + } + let buf = readFileSync(filePath) + let calcdhash = AssetGuard.calculateHash(buf, 'sha1') + let valid = checksums.includes(calcdhash) + if (!valid && filePath.endsWith('.jar')) { + valid = AssetGuard.validateForgeJar(Buffer.from(filePath), checksums) + } + return valid + } + return false + } + + /** + * Validates a forge jar file dependency who declares a checksums.sha1 file. + * This can be an expensive task as it usually requires that we calculate thousands + * of hashes. + * + * @param {Buffer} buffer The buffer of the jar file. + * @param {Array.} checksums The checksums listed in the forge version index. + * @returns {boolean} True if all hashes declared in the checksums.sha1 file match the actual hashes. + */ + private static validateForgeJar(buffer: Buffer, checksums: string[]): boolean { + // Double pass method was the quickest I found. I tried a version where we store data + // to only require a single pass, plus some quick cleanup but that seemed to take slightly more time. + + const hashes: Record = {} + let expected: Record = {} + + const zip = new AdmZip(buffer) + const zipEntries = zip.getEntries() + + //First pass + for (let i = 0; i < zipEntries.length; i++) { + let entry = zipEntries[i] + if (entry.entryName === 'checksums.sha1') { + expected = AssetGuard.parseChecksumsFile(zip.readAsText(entry)) + } + hashes[entry.entryName] = AssetGuard.calculateHash(entry.getData(), 'sha1') + } + + if (!checksums.includes(hashes['checksums.sha1'])) { + return false + } + + //Check against expected + const expectedEntries = Object.keys(expected) + for (let i = 0; i < expectedEntries.length; i++) { + if (expected[expectedEntries[i]] !== hashes[expectedEntries[i]]) { + return false + } + } + return true + } + + // #endregion + + // Miscellaneous Static Functions + // #region + + /** + * Extracts and unpacks a file from .pack.xz format. + * + * @param {Array.} filePaths The paths of the files to be extracted and unpacked. + * @returns {Promise.} An empty promise to indicate the extraction has completed. + */ + private static extractPackXZ(filePaths: string[], javaExecutable: string): Promise { + const extractLogger = LoggerUtil.getLogger('PackXZExtract') + extractLogger.info('Starting') + return new Promise((resolve, reject) => { + let libPath: string; + if (DevUtil.IsDev) { + libPath = join(process.cwd(), 'libraries', 'java', 'PackXZExtract.jar') + } else { + if (process.platform === 'darwin') { + libPath = join(process.cwd(), 'Contents', 'Resources', 'libraries', 'java', 'PackXZExtract.jar') + } else { + libPath = join(process.cwd(), 'resources', 'libraries', 'java', 'PackXZExtract.jar') + } + } + + const filePath = filePaths.join(',') + const child = spawn(javaExecutable, ['-jar', libPath, '-packxz', filePath]) + child.stdout.on('data', (data) => { + extractLogger.info(data.toString('utf8')) + }) + child.stderr.on('data', (data) => { + extractLogger.info(data.toString('utf8')) + }) + child.on('close', (code, _signal) => { + extractLogger.info('Exited with code', code) + resolve(undefined); + }) + }) + } + + + /** + * 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.} A promise which resolves to the contents of forge's version.json. + */ + private static finalizeForgeAsset(asset: Asset, commonPath: string): Promise { + return new Promise((resolve, reject) => { + 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 = join(commonPath, 'versions', forgeVersion.id) + const versionFile = join(versionPath, forgeVersion.id + '.json') + if (!existsSync(versionFile)) { + ensureDirSync(versionPath) + writeFileSync(join(versionPath, forgeVersion.id + '.json'), zipEntries[i].getData()) + resolve(forgeVersion) + } else { + //Read the saved file to allow for user modifications. + resolve(JSON.parse(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 + + + + + + public totaldlsize = 0; + public progress = 0; + public assets = new DLTracker([], 0); + public libraries = new DLTracker([], 0); + public files = new DLTracker([], 0); + public forge = new DLTracker([], 0); + public java = new DLTracker([], 0); + public extractQueue: string[] = []; + + /** + * Create an instance of AssetGuard. + * On creation the object's properties are never-null default + * values. Each identifier is resolved to an empty DLTracker. + * + * @param {string} commonPath The common path for shared game files. + * @param {string} javaexec The path to a java executable which will be used + * to finalize installation. + */ + constructor( + public commonPath: string, + public javaexec: string + ) { + super() + this.commonPath = commonPath + this.javaexec = javaexec + } + + // 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.} Promise which resolves to the version data object. + */ + public async loadVersionData(version: string, force: boolean = false): Promise { + const versionPath = join(this.commonPath, 'versions', version) + const versionFile = join(versionPath, version + '.json') + + if (!existsSync(versionFile) || force) { + const url = await this.getVersionDataUrl(version) + if (!url) throw new Error("No URL"); + + //This download will never be tracked as it's essential and trivial. + logger.info('Preparing download of ' + version + ' assets.') + await ensureDir(versionPath); + + const response = await fetch(url); + const json = await response.json() as MinecraftGameVersionManifest; + response.text().then(text => { + writeFile(versionFile, response.text()); + }); + return json; + } + + return JSON.parse(await readFile(versionFile, 'utf-8')); + } + + /** + * Parses Mojang's version manifest and retrieves the url of the version + * data index. + * + * //TODO:Get the JSON to type + * + * @param {string} version The version to lookup. + * @returns {Promise.} Promise which resolves to the url of the version data index. + * If the version could not be found, resolves to null. + */ + public async getVersionDataUrl(versionId: string): Promise { + + const response = await fetch('https://launchermeta.mojang.com/mc/game/version_manifest.json'); + const manifest = await response.json() as MinecraftGameManifest; + const version = manifest.versions.find(v => v.id === versionId) + return version?.url || 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 {MinecraftGameVersionManifest} 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.} An empty promise to indicate the async processing has completed. + */ + public async validateAssets(versionData: MinecraftGameVersionManifest, force: boolean = false): Promise { + return this.assetChainIndexData(versionData, force); + } + + //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 {MinecraftGameVersionManifest} versionData + * @param {boolean} force + * @returns {Promise.} An empty promise to indicate the async processing has completed. + */ + private async assetChainIndexData(versionData: MinecraftGameVersionManifest, force: boolean = false): Promise { + //Asset index constants. + const assetIndex = versionData.assetIndex + const name = assetIndex.id + '.json' + const indexPath = join(this.commonPath, 'assets', 'indexes') + const assetIndexLoc = join(indexPath, name) + + let assetJson: MinecraftAssetJson; + if (force || !pathExistsSync(assetIndexLoc)) { + logger.info('Downloading ' + versionData.id + ' asset index.') + await ensureDir(indexPath) + + const response = await fetch(assetIndex.url); + assetJson = await response.json() as MinecraftAssetJson; + response.text().then(txt => { + writeFile(assetIndexLoc, txt, { encoding: 'utf8' }) + }); + } else { + assetJson = JSON.parse(await readFile(assetIndexLoc, 'utf-8')) as MinecraftAssetJson; + } + + return this.assetChainValidateAssets(assetJson) + } + + /** + * Private function used to chain the asset validation process. This function processes + * the assets and enqueues missing or invalid files. + * @param {MinecraftGameVersionManifest} versionData + * @param {boolean} force + * @returns {Promise.} An empty promise to indicate the async processing has completed. + */ + private assetChainValidateAssets(indexData: MinecraftAssetJson): Promise { + return new Promise((resolve, reject) => { + + //Asset constants + const resourceURL = 'https://resources.download.minecraft.net/' + const localPath = join(this.commonPath, 'assets') + const objectPath = join(localPath, 'objects') + + const assetDlQueue: Asset[] = [] + let dlSize = 0 + let acc = 0 + const total = Object.keys(indexData.objects).length + //const objKeys = Object.keys(data.objects) + asyncModule.forEachOfLimit(indexData.objects, 10, (value, key, cb) => { + acc++ + this.emit('progress', 'assets', acc, total) + const hash = value.hash + const assetName = join(hash.substring(0, 2), hash) + const urlName = hash.substring(0, 2) + '/' + hash + const ast = new Asset(key, hash, value.size, resourceURL + urlName, join(objectPath, assetName)) + if (!AssetGuard.validateLocal(ast.to, 'sha1', ast.hash)) { + dlSize += (ast.size * 1) + assetDlQueue.push(ast) + } + cb() + }, (err) => { + this.assets = new DLTracker(assetDlQueue, dlSize) + resolve(undefined) + }) + }) + } + + // #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 {MinecraftGameVersionManifest} versionData The version data for the assets. + * @returns {Promise.} An empty promise to indicate the async processing has completed. + */ + public validateLibraries(versionData: MinecraftGameVersionManifest): Promise { + return new Promise((resolve, reject) => { + + const libArr = versionData.libraries + const libPath = join(this.commonPath, 'libraries') + + const libDlQueue: Library[] = [] + let dlSize = 0 + + //Check validity of each library. If the hashs don't match, download the library. + asyncModule.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, join(libPath, artifact.path)) + if (!AssetGuard.validateLocal(libItm.to, 'sha1', libItm.hash)) { + dlSize += (libItm.size * 1) + libDlQueue.push(libItm) + } + } + cb() + }, (err) => { + this.libraries = new DLTracker(libDlQueue, dlSize) + resolve(undefined) + }) + }) + } + + // #endregion + + // Miscellaneous (Category=files) Validation Functions + // #region + + /** + * Public miscellaneous mojang file validation function. These files will be enqueued under + * the 'files' identifier. + * + * @param {MinecraftGameVersionManifest} versionData The version data for the assets. + * @returns {Promise.} An empty promise to indicate the async processing has completed. + */ + public async validateMiscellaneous(versionData: MinecraftGameVersionManifest): Promise { + await this.validateClient(versionData); + await this.validateLogConfig(versionData); + } + + /** + * Validate client file - artifact renamed from client.jar to '{version}'.jar. + * + * @param {MinecraftGameVersionManifest} 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.} An empty promise to indicate the async processing has completed. + */ + public async validateClient(versionData: MinecraftGameVersionManifest, force: boolean = false): Promise { + const clientData = versionData.downloads.client; + const version = versionData.id; + const targetPath = join(this.commonPath, 'versions', version); + const targetFile = version + '.jar'; + + let client = new Asset(version + ' client', clientData.sha1, clientData.size, clientData.url, join(targetPath, targetFile)); + + if (!AssetGuard.validateLocal(client.to, 'sha1', client.hash) || force) { + this.files.dlqueue.push(client); + this.files.dlsize += client.size * 1; + } + } + + /** + * Validate log config. + * + * @param {MinecraftGameVersionManifest} 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 {void} An empty promise to indicate the async processing has completed. + */ + public validateLogConfig(versionData: MinecraftGameVersionManifest): void { + const client = versionData.logging.client + const file = client.file + const targetPath = join(this.commonPath, 'assets', 'log_configs') + + if (!file.id) throw new Error("No file ID"); + const logConfig = new Asset(file.id, file.sha1, file.size, file.url, join(targetPath, file.id ?? '')) + + if (!AssetGuard.validateLocal(logConfig.to, 'sha1', logConfig.hash)) { + this.files.dlqueue.push(logConfig) + this.files.dlsize += logConfig.size * 1 + } + } + + // #endregion + + // Distribution (Category=forge) Validation Functions + // #region + + /** + * Validate the distribution. + * + * @param {Server} server The Server to validate. + * @returns {Server} A promise which resolves to the server distribution object. + */ + public validateDistribution(server: Server): Server { + this.forge = this.parseDistroModules(server.modules, server.minecraftVersion, server.id); + return server; + } + + public parseDistroModules(modules: Module[], version: string, servid: string) { + let assets: DistroAsset[] = []; + let asize = 0; + for (let module of modules) { + let modArtifact = module.artifact; + let finalPath = modArtifact.path; + let distroAsset = new DistroAsset(module.identifier, modArtifact.getHash(), Number(modArtifact.size), modArtifact.getURL(), finalPath, module.type) + const validationPath = finalPath.toLowerCase().endsWith('.pack.xz') + ? finalPath.substring(0, finalPath.toLowerCase().lastIndexOf('.pack.xz')) + : finalPath + + if (!AssetGuard.validateLocal(validationPath, 'MD5', distroAsset.hash)) { + asize += distroAsset.size * 1 + assets.push(distroAsset) + if (validationPath !== finalPath) this.extractQueue.push(finalPath) + } + + //Recursively process the submodules then combine the results. + if (module.subModules != null) { + let dltrack = this.parseDistroModules(module.subModules, version, servid) + asize += dltrack.dlsize * 1 + assets = assets.concat(dltrack.dlqueue as DistroAsset[]) + } + } + + return new DLTracker(assets, asize) + } + + /** + * Loads Forge's version.json data into memory for the specified server id. + * + * @param {Server} server The Server to load Forge data for. + * @returns {Promise.} A promise which resolves to Forge's version.json data. + */ + public async loadForgeData(server: Server): Promise { + const modules = server.modules + for (let module of modules) { + const type = module.type + if (type === DistroTypes.ForgeHosted || type === DistroTypes.Forge) { + if (MinecraftUtil.isForgeGradle3(server.minecraftVersion, module.artifactVersion)) { + // Read Manifest + for (let subModule of module.subModules) { + if (subModule.type === DistroTypes.VersionManifest) { + return JSON.parse(readFileSync(subModule.artifact.getPath(), 'utf-8')) + } + } + throw new Error('No forge version manifest found!') + } else { + const modArtifact = module.artifact + const artifactPath = modArtifact.getPath() + const asset = new DistroAsset(module.identifier, modArtifact.getHash(), Number(modArtifact.size), modArtifact.getURL(), artifactPath, type) + try { + let forgeData = await AssetGuard.finalizeForgeAsset(asset, this.commonPath) + return forgeData; + } catch (err) { + throw err; + } + } + } + } + throw new Error('No forge module found!') + } + + private 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 + // #region + + private enqueueOpenJDK(dataDir: string, mcVersion: string) { + const major = MinecraftUtil.mcVersionAtLeast('1.17', mcVersion) ? '17' : '8' + JavaGuard.latestOpenJDK(major).then(verData => { + if (verData != null) { + + dataDir = join(dataDir, 'runtime', 'x64') + const fDir = join(dataDir, verData.name) + //TODO : Verify it doesn't break a thing + const jre = new Asset(verData.name, '', verData.size, verData.uri, fDir) + this.java = new DLTracker([jre], jre.size, (asset) => { + if (verData.name.endsWith('zip')) { + + this.extractJdkZip(asset.to, dataDir) + + } else { + // Tar.gz + let h: string; + createReadStream(asset.to) + .on('error', err => logger.error(err)) + .pipe(createGunzip()) + .on('error', err => logger.error(err)) + .pipe(extract(dataDir, { + map: (header) => { + if (h == null) { + h = header.name + } + } + })) + .on('error', err => logger.error(err)) + .on('finish', () => { + unlink(asset.to, err => { + if (err) { + logger.error(err) + } + if (h.indexOf('/') > -1) { + h = h.substring(0, h.indexOf('/')) + } + const pos = join(dataDir, h) + this.emit('complete', 'java', JavaGuard.javaExecFromRoot(pos)) + }) + }) + } + }) + return true; + } else { + return false; + } + }) + + } + + public async extractJdkZip(zipPath: string, runtimeDir: string) { + + const zip = new StreamZipAsync({ + file: zipPath, + storeEntries: true + }); + + let pos = '' + try { + const entries = await zip.entries() + pos = join(runtimeDir, Object.keys(entries)[0]) + + logger.info('Extracting jdk..') + await zip.extract(null, runtimeDir) + logger.info('Cleaning up..') + await remove(zipPath) + logger.info('Jdk extraction complete.') + + } catch (err) { + logger.error(err) + } finally { + zip.close() + this.emit('complete', 'java', JavaGuard.javaExecFromRoot(pos)) + } + } + + // #endregion + + // #endregion + + // Control Flow Functions + // #region + + /** + * Initiate an async download process for an AssetGuard DLTracker. + * //TODO: really ? + * @param {string} identifier The identifier of the AssetGuard DLTracker. + * @param {number} limit Optional. The number of async processes to run in parallel. + * @returns {boolean} True if the process began, otherwise false. + */ + public startAsyncProcess(identifier: string, limit: number = 5): boolean { + + const dlTracker = this[identifier] + const dlQueue = dlTracker.dlqueue + + if (dlQueue.length > 0) { + logger.info('DLQueue', dlQueue) + asyncModule.eachLimit(dlQueue, limit, (asset, cb) => { + + ensureDirSync(join(asset.to, '..')) + + const req = request(asset.from) + req.pause() + + req.on('response', (resp) => { + + if (resp.statusCode === 200) { + + let doHashCheck = false + const contentLength = parseInt(resp.headers['content-length'] ?? '') + + if (contentLength !== asset.size) { + logger.warn(`WARN: Got ${contentLength} bytes for ${asset.id}: Expected ${asset.size}`) + doHashCheck = true + + // Adjust download + this.totaldlsize -= asset.size + this.totaldlsize += contentLength + } + + const writeStream = createWriteStream(asset.to) + writeStream.on('close', () => { + if (dlTracker.callback != null) { + dlTracker.callback.apply(dlTracker, [asset, self]) + } + + if (doHashCheck) { + const isValid = AssetGuard.validateLocal(asset.to, asset.type != null ? 'md5' : 'sha1', asset.hash) + if (isValid) { + logger.warn(`Hashes match for ${asset.id}, byte mismatch is an issue in the distro index.`) + } else { + logger.error(`Hashes do not match, ${asset.id} may be corrupted.`) + } + } + cb() + }); + req.pipe(writeStream) + req.resume() + + } else { + req.abort() + logger.error(`Failed to download ${asset.id}(${typeof asset.from === 'object' ? asset.from.url : asset.from}). Response code ${resp.statusCode}`) + this.progress += asset.size * 1 + this.emit('progress', 'download', this.progress, this.totaldlsize) + cb() + } + + }) + + req.on('error', (err) => { + this.emit('error', 'download', err) + }) + + req.on('data', (chunk) => { + this.progress += chunk.length + this.emit('progress', 'download', this.progress, this.totaldlsize) + }) + + }, (err) => { + + if (err) { + logger.warn('An item in ' + identifier + ' failed to process') + } else { + logger.info('All ' + identifier + ' have been processed successfully') + } + + //this.totaldlsize -= dlTracker.dlsize + //this.progress -= dlTracker.dlsize + self[identifier] = new DLTracker([], 0) + + if (this.progress >= this.totaldlsize) { + if (this.extractQueue.length > 0) { + this.emit('progress', 'extract', 1, 1) + //this.emit('extracting') + AssetGuard.extractPackXZ(this.extractQueue, this.javaexec).then(() => { + this.extractQueue = [] + this.emit('complete', 'download') + }) + } else { + this.emit('complete', 'download') + } + } + + }) + return true + } + return false + } + + /** + * //TODO: Refacto + * This function will initiate the download processed for the specified identifiers. If no argument is + * given, all identifiers will be initiated. Note that in order for files to be processed you need to run + * the processing function corresponding to that identifier. If you run this function without processing + * the files, it is likely nothing will be enqueued in the object and processing will complete + * immediately. Once all downloads are complete, this function will fire the 'complete' event on the + * global object instance. + * + * @param {Array.<{id: string, limit: number}>} identifiers Optional. The identifiers to process and corresponding parallel async task limit. + */ + processDlQueues(identifiers = [{ id: 'assets', limit: 20 }, { id: 'libraries', limit: 5 }, { id: 'files', limit: 5 }, { id: 'forge', limit: 5 }]) { + return new Promise((resolve, _reject) => { + let shouldFire = true + + // Assign dltracking variables. + this.totaldlsize = 0 + this.progress = 0 + + for (let iden of identifiers) { + this.totaldlsize += this[iden.id].dlsize + } + + this.once('complete', () => { + resolve(undefined) + }) + + for (let iden of identifiers) { + let r = this.startAsyncProcess(iden.id, iden.limit) + if (r) shouldFire = false + } + + if (shouldFire) { + this.emit('complete', 'download') + } + }) + } + + + async validateEverything(serverid: string, dev = false) { + + try { + if (!ConfigManager.isLoaded) ConfigManager.load() + + DistroManager.setDevMode(dev) + const distroIndex = await DistroManager.pullLocal() + + const server = distroIndex.getServer(serverid) + if (!server) throw new Error(`No Such Server ${serverid}`) + // Validate Everything + + await this.validateDistribution(server) + this.emit('validate', 'distribution') + const versionData = await this.loadVersionData(server.minecraftVersion) + 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 + } + } + + + } + + + +} \ No newline at end of file diff --git a/src/services/DiscordRichPresence.ts b/src/services/DiscordRichPresence.ts new file mode 100644 index 00000000..a16835b7 --- /dev/null +++ b/src/services/DiscordRichPresence.ts @@ -0,0 +1,62 @@ +import { LoggerUtil } from "helios-core/."; +import { Client } from "discord-rpc-patch"; +import RPCClient from "discord-rpc-patch/src/client"; + +const logger = LoggerUtil.getLogger('DiscordWrapper') + +export class DiscordRichPresence { + private static client?: RPCClient; + private static activity?: { + details: string, + state: string, + largeImageKey: string, + largeImageText: string, + smallImageKey: string, + smallImageText: string, + startTimestamp: number, + instance: boolean, + }; + + public static initRPC(genSettings, servSettings, initialDetails = 'Waiting for this.Client..') { + this.client = new Client({ transport: 'ipc' }) + + this.activity = { + details: initialDetails, + state: 'Server: ' + servSettings.shortId, + largeImageKey: servSettings.largeImageKey, + largeImageText: servSettings.largeImageText, + smallImageKey: genSettings.smallImageKey, + smallImageText: genSettings.smallImageText, + startTimestamp: new Date().getTime(), + instance: false + } + + this.client.on('ready', () => { + logger.info('Discord RPC Connected') + this.client.setActivity(activity) + }) + + this.client.login({ clientId: genSettings.clientId }).catch(error => { + if (error.message.includes('ENOENT')) { + logger.info('Unable to initialize Discord Rich Presence, no client detected.') + } else { + logger.info('Unable to initialize Discord Rich Presence: ' + error.message, error) + } + }) + } + + public static updateDetails(details) { + if (!this.client || !this.activity) return; + this.activity.details = details + this.client.setActivity(this.activity) + } + + public static shutdownRPC() { + if (!this.client) return + this.client.clearActivity() + this.client.destroy() + this.client = undefined + this.activity = undefined + } + +} \ No newline at end of file diff --git a/src/services/JavaGuard.ts b/src/services/JavaGuard.ts index c460cde8..4b7a39c6 100644 --- a/src/services/JavaGuard.ts +++ b/src/services/JavaGuard.ts @@ -1,5 +1,729 @@ import * as EventEmitter from "events"; +import { LoggerUtil } from 'helios-core/.'; +import { DevUtil } from '../util/DevUtil'; +import { join } from 'path'; +import { existsSync, pathExists, readdir } from 'fs-extra'; +import Registry from "winreg"; +import { MinecraftUtil } from '../util/MinecraftUtil'; +import { exec } from "child_process"; +import nodeDiskInfo from "node-disk-info"; +import fetch from "node-fetch"; +import { AdoptiumBinary, JavaMetaObject, JavaRuntimeVersion } from '../util/JavaType'; + +const logger = LoggerUtil.getLogger("JavaGuard"); export class JavaGuard extends EventEmitter { + + /** + * Fetch the last open JDK binary. + * + * HOTFIX: Uses Corretto 8 for macOS. + * See: https://github.com/dscalzi/HeliosLauncher/issues/70 + * See: https://github.com/AdoptOpenJDK/openjdk-support/issues/101 + * + * @param {string} major The major version of Java to fetch. + * + * @returns {Promise.} Promise which resolved to an object containing the JRE download data. + */ + public static latestOpenJDK(major = '8') { + return process.platform === 'darwin' ? + this.latestCorretto(major) : this.latestAdoptium(major); + } + + private static async latestAdoptium(major: string) { + const majorNum = Number(major) + const sanitizedOS = process.platform === 'win32' ? 'windows' : (process.platform === 'darwin' ? 'mac' : process.platform) + const url = `https://api.adoptium.net/v3/assets/latest/${major}/hotspot?vendor=eclipse` + + const response = await fetch(url).catch(_e => { logger.error(_e); return null }); + if (!response) return null; + const json = await response.json() as AdoptiumBinary[] + + const targetBinary = json.find(entry => { + return entry.version.major === majorNum + && entry.binary.os === sanitizedOS + && entry.binary.image_type === 'jdk' + && entry.binary.architecture === 'x64' + }); + + return targetBinary ? + { + uri: targetBinary.binary.package.link, + size: targetBinary.binary.package.size, + name: targetBinary.binary.package.name + } + : null + } + + private static async latestCorretto(major: string) { + let sanitizedOS: string, ext: string; + switch (process.platform) { + case 'win32': + sanitizedOS = 'windows' + ext = 'zip' + break + case 'darwin': + sanitizedOS = 'macos' + ext = 'tar.gz' + break + case 'linux': + sanitizedOS = 'linux' + ext = 'tar.gz' + break + default: + sanitizedOS = process.platform + ext = 'tar.gz' + break + } + + const arch = DevUtil.isARM64 ? 'aarch64' : 'x64' + const url = `https://corretto.aws/downloads/latest/amazon-corretto-${major}-${arch}-${sanitizedOS}-jdk.${ext}` + + const response = await fetch(url).catch(e => { logger.error(e); return null; }); + if (!response) return null; + + return { + uri: url, + size: Number(response.headers.get("content-length")), + name: url.substring(url.lastIndexOf('/') + 1) + } + } + + /** + * Returns the path of the OS-specific executable for the given Java + * installation. Supported OS's are win32, darwin, linux. + * + * @param {string} rootDir The root directory of the Java installation. + * @returns {string} The path to the Java executable. + */ + public static javaExecFromRoot(rootDir) { + if (process.platform === 'win32') { + return join(rootDir, 'bin', 'javaw.exe') + } else if (process.platform === 'darwin') { + return join(rootDir, 'Contents', 'Home', 'bin', 'java') + } else if (process.platform === 'linux') { + return join(rootDir, 'bin', 'java') + } + return rootDir + } + + /** + * Check to see if the given path points to a Java executable. + * + * @param {string} pth The path to check against. + * @returns {boolean} True if the path points to a Java executable, otherwise false. + */ + public static isJavaExecPath(pth: string) { + if (pth == null) { + return false + } + if (process.platform === 'win32') { + return pth.endsWith(join('bin', 'javaw.exe')) + } else if (process.platform === 'darwin') { + return pth.endsWith(join('bin', 'java')) + } else if (process.platform === 'linux') { + return pth.endsWith(join('bin', 'java')) + } + return false + } + + + /** + * Load Mojang's launcher.json file. + * + * //TODO: Import the launcher.json to have autocompletion + * + * @returns {Promise.} Promise which resolves to Mojang's launcher.json object. + */ + public static async loadMojangLauncherData() { + const response = await fetch('https://launchermeta.mojang.com/mc/launcher.json').catch(e => { logger.error(e); return null }); + if (!response) return null; + + return response.json(); + } + + /** + * Parses a **full** Java Runtime version string and resolves + * the version information. Dynamically detects the formatting + * to use. + * + * @param {string} versionString Full version string to parse. + * @returns Object containing the version information. + */ + static parseJavaRuntimeVersion(versionString: string): JavaRuntimeVersion { + const major = versionString.split('.')[0] + return major == "1" ? + JavaGuard.parseJavaRuntimeVersion_8(versionString) + : JavaGuard.parseJavaRuntimeVersion_9(versionString) + } + + /** + * Parses a **full** Java Runtime version string and resolves + * the version information. Uses Java 8 formatting. + * + * @param {string} versionString Full version string to parse. + * @returns Object containing the version information. + */ + public static parseJavaRuntimeVersion_8(versionString: string) { + // 1.{major}.0_{update}-b{build} + // ex. 1.8.0_152-b16 + let pts = versionString.split('-') + const build = parseInt(pts[1].substring(1)) + + pts = pts[0].split('_') + + const update = parseInt(pts[1]) + const major = parseInt(pts[0].split('.')[1]) + + return { + build, + update, + major, + minor: undefined, + revision: undefined + } + } + + /** + * Parses a **full** Java Runtime version string and resolves + * the version information. Uses Java 9+ formatting. + * + * @param {string} verString Full version string to parse. + * @returns Object containing the version information. + */ + public static parseJavaRuntimeVersion_9(verString: string) { + // {major}.{minor}.{revision}+{build} + // ex. 10.0.2+13 + let pts = verString.split('+') + const build = parseInt(pts[1]) + + pts = pts[0].split('.') + + const major = parseInt(pts[0]) + const minor = parseInt(pts[1]) + const revision = parseInt(pts[2]) + return { + build, + major, + minor, + revision + } + } + + + /** + * Checks for the presence of the environment variable JAVA_HOME. If it exits, we will check + * to see if the value points to a path which exists. If the path exits, the path is returned. + * + * @returns {string} The path defined by JAVA_HOME, if it exists. Otherwise null. + */ + private static scanJavaHome() { + const jHome = process.env.JAVA_HOME + if (!jHome) return null; + try { + let res = existsSync(jHome) + return res ? jHome : null + } catch (err) { + // Malformed JAVA_HOME property. + return null + } + } + + /** + * Scans the registry for 64-bit Java entries. The paths of each entry are added to + * a set and returned. Currently, only Java 8 (1.8) is supported. + * + * @returns {Promise.>} A promise which resolves to a set of 64-bit Java root + * paths found in the registry. + */ + private static scanRegistry(): Promise> { + return new Promise((resolve, _reject) => { + // Keys for Java v9.0.0 and later: + // 'SOFTWARE\\JavaSoft\\JRE' + // 'SOFTWARE\\JavaSoft\\JDK' + // Forge does not yet support Java 9, therefore we do not. + + // Keys for Java 1.8 and prior: + const regKeys = [ + '\\SOFTWARE\\JavaSoft\\Java Runtime Environment', + '\\SOFTWARE\\JavaSoft\\Java Development Kit' + ] + + let keysDone = 0 + + const candidates = new Set() + + for (let i = 0; i < regKeys.length; i++) { + const key = new Registry({ + hive: Registry.HKLM, + key: regKeys[i], + arch: 'x64' + }) + key.keyExists((err, exists) => { + if (exists) { + key.keys((err, javaVers) => { + if (err) { + keysDone++ + console.error(err) + + // REG KEY DONE + // DUE TO ERROR + if (keysDone === regKeys.length) { + resolve(candidates) + } + } else { + if (javaVers.length === 0) { + // REG KEY DONE + // NO SUBKEYS + keysDone++ + if (keysDone === regKeys.length) { + resolve(candidates) + } + } else { + + let numDone = 0 + + for (let j = 0; j < javaVers.length; j++) { + const javaVer = javaVers[j] + const vKey = javaVer.key.substring(javaVer.key.lastIndexOf('\\') + 1) + // Only Java 8 is supported currently. + if (parseFloat(vKey) === 1.8) { + javaVer.get('JavaHome', (err, res) => { + const jHome = res.value + if (jHome.indexOf('(x86)') === -1) { + candidates.add(jHome) + } + + // SUBKEY DONE + + numDone++ + if (numDone === javaVers.length) { + keysDone++ + if (keysDone === regKeys.length) { + resolve(candidates) + } + } + }) + } else { + + // SUBKEY DONE + // NOT JAVA 8 + + numDone++ + if (numDone === javaVers.length) { + keysDone++ + if (keysDone === regKeys.length) { + resolve(candidates) + } + } + } + } + } + } + }) + } else { + + // REG KEY DONE + // DUE TO NON-EXISTANCE + + keysDone++ + if (keysDone === regKeys.length) { + resolve(candidates) + } + } + }) + } + + }) + + } + + /** + * See if JRE exists in the Internet Plug-Ins folder. + * + * @returns {string} The path of the JRE if found, otherwise null. + */ + private static scanInternetPlugins() { + // /Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/bin/java + const pth = '/Library/Internet Plug-Ins/JavaAppletPlugin.plugin' + const res = existsSync(JavaGuard.javaExecFromRoot(pth)) + return res ? pth : null + } + + /** + * Scan a directory for root JVM folders. + * + * @param {string} scanDir The directory to scan. + * @returns {Promise.>} A promise which resolves to a set of the discovered + * root JVM folders. + */ + private static async scanFileSystem(scanDir) { + let res = new Set() + if (await pathExists(scanDir)) { + + const files = await readdir(scanDir) + for (let i = 0; i < files.length; i++) { + + const combinedPath = join(scanDir, files[i]) + const execPath = JavaGuard.javaExecFromRoot(combinedPath) + + if (await pathExists(execPath)) { + res.add(combinedPath) + } + } + } + + return res + } + + /** + * Sort an array of JVM meta objects. Best candidates are placed before all others. + * Sorts based on version and gives priority to JREs over JDKs if versions match. + * + * @param {Object[]} validArr An array of JVM meta objects. + * @returns {Object[]} A sorted array of JVM meta objects. + */ + private static sortValidJavaArray(validArr: JavaMetaObject[]) { + const retArr = validArr.sort((a, b) => { + + if (a.version.major === b.version.major) { + + if (a.version.major < 9) { + // Java 8 + if (a.version.update === b.version.update) { + if (a.version.build === b.version.build) { + + // Same version, give priority to JRE. + if (a.execPath!.toLowerCase().indexOf('jdk') > -1) { + return b.execPath!.toLowerCase().indexOf('jdk') > -1 ? 0 : 1 + } else { + return -1 + } + + } else { + return a.version.build > b.version.build ? -1 : 1 + } + } else { + return a.version.update! > b.version.update! ? -1 : 1 + } + } else { + // Java 9+ + if (a.version.minor === b.version.minor) { + if (a.version.revision === b.version.revision) { + + // Same version, give priority to JRE. + if (a.execPath!.toLowerCase().indexOf('jdk') > -1) { + return b.execPath!.toLowerCase().indexOf('jdk') > -1 ? 0 : 1 + } else { + return -1 + } + + } else { + return a.version.revision! > b.version.revision! ? -1 : 1 + } + } else { + return a.version.minor! > b.version.minor! ? -1 : 1 + } + } + + } else { + return a.version.major > b.version.major ? -1 : 1 + } + }) + + return retArr + } + + constructor(public mcVersion: string) { + super(); + + } + + /** + * Validates the output of a JVM's properties. Currently validates that a JRE is x64 + * and that the major = 8, update > 52. + * + * @param {string} stderr The output to validate. + * + * @returns {Promise.} A promise which resolves to a meta object about the JVM. + * The validity is stored inside the `valid` property. + */ + private validateJVMProperties(stderr: string) { + const res = stderr + const props = res.split('\n') + + const goal = 2 + let checksum = 0 + + const meta: any = {} + + for (let i = 0; i < props.length; i++) { + if (props[i].indexOf('sun.arch.data.model') > -1) { + const arch = props[i].split('=')[1].trim() + const parsedArch = parseInt(arch) + logger.debug(props[i].trim()) + + if (parsedArch === 64) { + meta.arch = parsedArch + ++checksum + if (checksum === goal) break; + } + + } else if (props[i].indexOf('java.runtime.version') > -1) { + let verString = props[i].split('=')[1].trim() + logger.debug(props[i].trim()) + const objectVersion = JavaGuard.parseJavaRuntimeVersion(verString) + // TODO implement a support matrix eventually. Right now this is good enough + // 1.7-1.16 = Java 8 + // 1.17+ = Java 17 + // Actual support may vary, but we're going with this rule for simplicity. + if (objectVersion.major < 9 && !MinecraftUtil.mcVersionAtLeast('1.17', this.mcVersion)) { + // Java 8 + if (objectVersion.major === 8 && objectVersion.update! > 52) { + meta.version = objectVersion + ++checksum + if (checksum === goal) break; + } + } else if (objectVersion.major >= 17 && MinecraftUtil.mcVersionAtLeast('1.17', this.mcVersion)) { + // Java 9+ + meta.version = objectVersion + ++checksum + if (checksum === goal) break; + } + // Space included so we get only the vendor. + } else if (props[i].lastIndexOf('java.vendor ') > -1) { + let vendorName = props[i].split('=')[1].trim() + logger.debug(props[i].trim()) + meta.vendor = vendorName + } else if (props[i].indexOf('os.arch') > -1) { + meta.isARM = props[i].split('=')[1].trim() === 'aarch64' + } + } + + meta.valid = checksum === goal + + return meta + } + + + /** + * Validates that a Java binary is at least 64 bit. This makes use of the non-standard + * command line option -XshowSettings:properties. The output of this contains a property, + * sun.arch.data.model = ARCH, in which ARCH is either 32 or 64. This option is supported + * in Java 8 and 9. Since this is a non-standard option. This will resolve to true if + * the function's code throws errors. That would indicate that the option is changed or + * removed. + * + * @param {string} binaryExecPath Path to the java executable we wish to validate. + * + * @returns {Promise.} A promise which resolves to a meta object about the JVM. + * The validity is stored inside the `valid` property. + */ + private validateJavaBinary(binaryExecPath): Promise { + return new Promise((resolve, _reject) => { + if (!JavaGuard.isJavaExecPath(binaryExecPath)) { + resolve(null) + } else if (existsSync(binaryExecPath)) { + // Workaround (javaw.exe no longer outputs this information.) + logger.debug(typeof binaryExecPath) + if (binaryExecPath.indexOf('javaw.exe') > -1) { + binaryExecPath.replace('javaw.exe', 'java.exe') + } + exec('"' + binaryExecPath + '" -XshowSettings:properties', (_err, _stdout, stderr) => { + try { + // Output is stored in stderr? + resolve(this.validateJVMProperties(stderr) as JavaMetaObject) + } catch (err) { + // Output format might have changed, validation cannot be completed. + resolve(null) + } + }) + } else { + resolve(null) + } + }) + + } + + /** + * + * @param {Set.} rootSet A set of JVM root strings to validate. + * @returns {Promise.} A promise which resolves to an array of meta objects + * for each valid JVM root directory. + */ + private async validateJavaRootSet(rootSet: Set) { + + const rootArr = Array.from(rootSet) + const validArr: JavaMetaObject[] = [] + + for (let i = 0; i < rootArr.length; i++) { + + const execPath = JavaGuard.javaExecFromRoot(rootArr[i]) + + let metaObj: JavaMetaObject | null = await this.validateJavaBinary(execPath); + if (!metaObj) continue; + + metaObj.execPath = execPath + validArr.push(metaObj) + } + + return validArr + + } + + /** + * Attempts to find a valid x64 installation of Java on Windows machines. + * Possible paths will be pulled from the registry and the JAVA_HOME environment + * variable. The paths will be sorted with higher versions preceeding lower, and + * JREs preceeding JDKs. The binaries at the sorted paths will then be validated. + * The first validated is returned. + * + * Higher versions > Lower versions + * If versions are equal, JRE > JDK. + * + * @param {string} dataDir The base launcher directory. + * @returns {Promise.} A Promise which resolves to the executable path of a valid + * x64 Java installation. If none are found, null is returned. + */ + private async win32JavaValidate(dataDir: string): Promise { + + // Get possible paths from the registry. + let pathSet1 = await JavaGuard.scanRegistry() + if (pathSet1.size === 0) { + + // Do a manual file system scan of program files. + // Check all drives + const driveMounts = nodeDiskInfo.getDiskInfoSync().map(({ mounted }) => mounted) + for (const mount of driveMounts) { + pathSet1 = new Set([ + ...pathSet1, + ...(await JavaGuard.scanFileSystem(`${mount}\\Program Files\\Java`)), + ...(await JavaGuard.scanFileSystem(`${mount}\\Program Files\\Eclipse Adoptium`)), + ...(await JavaGuard.scanFileSystem(`${mount}\\Program Files\\Eclipse Foundation`)), + ...(await JavaGuard.scanFileSystem(`${mount}\\Program Files\\AdoptOpenJDK`)) + ]) + } + + } + + // Get possible paths from the data directory. + const pathSet2 = await JavaGuard.scanFileSystem(join(dataDir, 'runtime', 'x64')) + + // Merge the results. + const uberSet = new Set([...pathSet1, ...pathSet2]) + + // Validate JAVA_HOME. + const jHome = JavaGuard.scanJavaHome() + if (jHome != null && jHome.indexOf('(x86)') === -1) { + uberSet.add(jHome) + } + + let pathArr = await this.validateJavaRootSet(uberSet) + pathArr = JavaGuard.sortValidJavaArray(pathArr) + + return pathArr.length > 0 ? pathArr[0].execPath! : null; + } + + /** + * Attempts to find a valid x64 installation of Java on MacOS. + * The system JVM directory is scanned for possible installations. + * The JAVA_HOME enviroment variable and internet plugins directory + * are also scanned and validated. + * + * Higher versions > Lower versions + * If versions are equal, JRE > JDK. + * + * @param {string} dataDir The base launcher directory. + * @returns {Promise.} A Promise which resolves to the executable path of a valid + * x64 Java installation. If none are found, null is returned. + * + * Added: On the system with ARM architecture attempts to find aarch64 Java. + * + */ + private async darwinJavaValidate(dataDir: string): Promise { + + const pathSet1 = await JavaGuard.scanFileSystem('/Library/Java/JavaVirtualMachines') + const pathSet2 = await JavaGuard.scanFileSystem(join(dataDir, 'runtime', 'x64')) + + const uberSet = new Set([...pathSet1, ...pathSet2]) + + // Check Internet Plugins folder. + const iPPath = JavaGuard.scanInternetPlugins() + if (iPPath != null) { + uberSet.add(iPPath) + } + + // Check the JAVA_HOME environment variable. + let jHome = JavaGuard.scanJavaHome() + if (jHome != null) { + // Ensure we are at the absolute root. + if (jHome.includes('/Contents/Home')) { + jHome = jHome.substring(0, jHome.indexOf('/Contents/Home')) + } + uberSet.add(jHome) + } + + let pathArr = await this.validateJavaRootSet(uberSet) + pathArr = JavaGuard.sortValidJavaArray(pathArr) + + if (pathArr.length > 0) { + + // TODO Revise this a bit, seems to work for now. Discovery logic should + // probably just filter out the invalid architectures before it even + // gets to this point. + if (DevUtil.isARM64) { + return pathArr.find(({ isARM }) => isARM)?.execPath ?? null + } else { + return pathArr.find(({ isARM }) => !isARM)?.execPath ?? null + } + + } else { + return null + } + } + + + /** + * Attempts to find a valid x64 installation of Java on Linux. + * The system JVM directory is scanned for possible installations. + * The JAVA_HOME enviroment variable is also scanned and validated. + * + * Higher versions > Lower versions + * If versions are equal, JRE > JDK. + * + * @param {string} dataDir The base launcher directory. + * @returns {Promise.} A Promise which resolves to the executable path of a valid + * x64 Java installation. If none are found, null is returned. + */ + async linuxJavaValidate(dataDir: string): Promise { + + const pathSet1 = await JavaGuard.scanFileSystem('/usr/lib/jvm') + const pathSet2 = await JavaGuard.scanFileSystem(join(dataDir, 'runtime', 'x64')) + + const uberSet = new Set([...pathSet1, ...pathSet2]) + + // Validate JAVA_HOME + const jHome = JavaGuard.scanJavaHome() + if (jHome != null) { + uberSet.add(jHome) + } + + let pathArr = await this.validateJavaRootSet(uberSet) + pathArr = JavaGuard.sortValidJavaArray(pathArr) + + return pathArr.length > 0 ? pathArr[0].execPath! : null; + } + + /** + * Retrieve the path of a valid x64 Java installation. + * + * @param {string} dataDir The base launcher directory. + * @returns {string} A path to a valid x64 Java installation, null if none found. + */ + public async validateJava(dataDir) { + return this[process.platform + 'JavaValidate'](dataDir) + } + + } \ No newline at end of file diff --git a/src/services/ProcessBuilder.ts b/src/services/ProcessBuilder.ts index 31d72f19..f271c332 100644 --- a/src/services/ProcessBuilder.ts +++ b/src/services/ProcessBuilder.ts @@ -12,6 +12,8 @@ import { DistroTypes } from "../manager/DistroManager"; import { Library } from "../models/Library"; import { Module } from "../models/Module"; import { Required } from "../models/Required"; +import { ArgumentRule, MinecraftGameVersionManifest } from '../dto/Minecraft'; +import { Server } from '../models/Server'; const logger = LoggerUtil.getLogger('ProcessBuilder') export default class ProcessBuilder { @@ -42,14 +44,14 @@ export default class ProcessBuilder { public llPath?: string; constructor( - public server, - public versionData, + public server: Server, + public versionData: MinecraftGameVersionManifest, public forgeData, public authUser, public launcherVersion ) { - this.gameDir = join(ConfigManager.instanceDirectory, server.getID()) + this.gameDir = join(ConfigManager.instanceDirectory, server.id) this.commonDir = ConfigManager.commonDirectory this.versionData = versionData this.forgeData = forgeData @@ -72,27 +74,27 @@ export default class ProcessBuilder { process.throwDeprecation = true this.setupLiteLoader() logger.info('Using liteloader:', this.usingLiteLoader) - const modObj = this.resolveModConfiguration(ConfigManager.getModConfigurationForServer(this.server.getID()).mods, this.server.getModules()) + const modObj = this.resolveModConfiguration(ConfigManager.getModConfigurationForServer(this.server.id).mods, this.server.modules) // Mod list below 1.13 - if (!MinecraftUtil.mcVersionAtLeast('1.13', this.server.getMinecraftVersion())) { + if (!MinecraftUtil.mcVersionAtLeast('1.13', this.server.minecraftVersion)) { this.constructJSONModList('forge', modObj.forgeMods, true) if (this.usingLiteLoader) { this.constructJSONModList('liteloader', modObj.liteMods, true) } } - const uberModArr = modObj.forgeMods.concat(modObj.liteMods) - let args = this.constructJVMArguments(uberModArr, tempNativePath) + const everyMods = modObj.forgeMods.concat(modObj.liteMods) + let args = this.constructJVMArguments(everyMods, tempNativePath) - if (MinecraftUtil.mcVersionAtLeast('1.13', this.server.getMinecraftVersion())) { + if (MinecraftUtil.mcVersionAtLeast('1.13', this.server.minecraftVersion)) { //args = args.concat(this.constructModArguments(modObj.forgeMods)) args = args.concat(this.constructModList(modObj.forgeMods)) } logger.info('Launch Arguments:', args) - const child = spawn(ConfigManager.getJavaExecutable(this.server.getID()), args, { + const child = spawn(ConfigManager.getJavaExecutable(this.server.id), args, { cwd: this.gameDir, detached: ConfigManager.getLaunchDetached() }) @@ -132,20 +134,20 @@ export default class ProcessBuilder { * mod. It must not be declared as a submodule. */ public setupLiteLoader() { - for (let ll of this.server.getModules()) { - if (ll.getType() === DistroTypes.LiteLoader) { - if (!ll.getRequired().isRequired()) { - const modCfg = ConfigManager.getModConfigurationForServer(this.server.getID()).mods - if (ProcessBuilder.isModEnabled(modCfg[ll.getVersionlessID()], ll.getRequired())) { - if (existsSync(ll.getArtifact().getPath())) { + for (let module of this.server.modules) { + if (module.type === DistroTypes.LiteLoader) { + if (!module.required.isRequired) { + const modCfg = ConfigManager.getModConfigurationForServer(this.server.id).mods + if (ProcessBuilder.isModEnabled(modCfg[module.versionlessID], module.required)) { + if (existsSync(module.artifact.getPath())) { this.usingLiteLoader = true - this.llPath = ll.getArtifact().getPath() + this.llPath = module.artifact.getPath() } } } else { - if (existsSync(ll.getArtifact().getPath())) { + if (existsSync(module.artifact.getPath())) { this.usingLiteLoader = true - this.llPath = ll.getArtifact().getPath() + this.llPath = module.artifact.getPath() } } } @@ -168,7 +170,7 @@ export default class ProcessBuilder { for (let module of modules) { const type = module.type; if (type === DistroTypes.ForgeMod || type === DistroTypes.LiteMod || type === DistroTypes.LiteLoader) { - const isRequired = !module.required.isRequired() + const isRequired = !module.required.isRequired const isEnabled = ProcessBuilder.isModEnabled(modConfig[module.versionlessID], module.required) if (!isRequired || (isRequired && isEnabled)) { if (module.hasSubModules) { @@ -209,7 +211,7 @@ export default class ProcessBuilder { modRef: [] } - const ids = [] + const ids: string[] = [] if (type === 'forge') { for (let mod of mods) { ids.push(mod.extensionlessID) @@ -237,8 +239,8 @@ export default class ProcessBuilder { * @param {string} tempNativePath The path to store the native libraries. * @returns {Array.} An array containing the full JVM arguments for this process. */ - public constructJVMArguments(mods: Module, tempNativePath: string) { - if (MinecraftUtil.mcVersionAtLeast('1.13', this.server.getMinecraftVersion())) { + public constructJVMArguments(mods: Module[], tempNativePath: string): string[] { + if (MinecraftUtil.mcVersionAtLeast('1.13', this.server.minecraftVersion)) { return this.constructJVMArguments113(mods, tempNativePath) } else { return this.constructJVMArguments112(mods, tempNativePath) @@ -258,7 +260,7 @@ export default class ProcessBuilder { public classpathArg(mods: Module[], tempNativePath: string) { let cpArgs: string[] = [] - if (!MinecraftUtil.mcVersionAtLeast('1.17', this.server.getMinecraftVersion())) { + if (!MinecraftUtil.mcVersionAtLeast('1.17', this.server.minecraftVersion)) { // Add the version.jar to the classpath. // Must not be added to the classpath for Forge 1.17+. const version = this.versionData.id @@ -340,7 +342,7 @@ export default class ProcessBuilder { * @returns {{[id: string]: string}} An object containing the paths of each library this server requires. */ private resolveServerLibraries(mods: Module[]) { - const modules: Module[] = this.server.getModules(); + const modules: Module[] = this.server.modules; let libs: Record = {} // Locate Forge/Libraries @@ -351,6 +353,8 @@ export default class ProcessBuilder { if (!module.hasSubModules) continue; const res = this.resolveModuleLibraries(module) if (res.length > 0) { + + //TODO: I don't understand why ? libs = { ...libs, ...res } } } @@ -362,6 +366,7 @@ export default class ProcessBuilder { if (!mod.hasSubModules) continue; const res = this.resolveModuleLibraries(mods[i]) if (res.length > 0) { + //TODO: I don't understand why ? libs = { ...libs, ...res } } } @@ -539,9 +544,9 @@ export default class ProcessBuilder { args.push(`-Xdock:name=${ConfigManager.launcherName.replace(" ", "")}`) args.push('-Xdock:icon=' + join(__dirname, '..', 'images', 'minecraft.icns')) } - args.push('-Xmx' + ConfigManager.getMaxRAM(this.server.getID())) - args.push('-Xms' + ConfigManager.getMinRAM(this.server.getID())) - args = args.concat(ConfigManager.getJVMOptions(this.server.getID())) + args.push('-Xmx' + ConfigManager.getMaxRAM(this.server.id)) + args.push('-Xms' + ConfigManager.getMinRAM(this.server.id)) + args = args.concat(ConfigManager.getJVMOptions(this.server.id)) args.push('-Djava.library.path=' + tempNativePath) // Main Java Class @@ -563,12 +568,12 @@ export default class ProcessBuilder { * @param {string} tempNativePath The path to store the native libraries. * @returns {Array.} An array containing the full JVM arguments for this process. */ - private constructJVMArguments113(mods: Module, tempNativePath: string) { + private constructJVMArguments113(mods: Module[], tempNativePath: string): string[] { const argDiscovery = /\${*(.*)}/ // JVM Arguments First - let args: string[] = this.versionData.arguments.jvm + let args = this.versionData.arguments.jvm // Debug securejarhandler // args.push('-Dbsl.debug=true') @@ -590,9 +595,9 @@ export default class ProcessBuilder { args.push(`-Xdock:name=${ConfigManager.launcherName.replace(" ", "")}`) args.push('-Xdock:icon=' + join(__dirname, '..', 'images', 'minecraft.icns')) } - args.push('-Xmx' + ConfigManager.getMaxRAM(this.server.getID())) - args.push('-Xms' + ConfigManager.getMinRAM(this.server.getID())) - args = args.concat(ConfigManager.getJVMOptions(this.server.getID())) + args.push('-Xmx' + ConfigManager.getMaxRAM(this.server.id)) + args.push('-Xms' + ConfigManager.getMinRAM(this.server.id)) + args = args.concat(ConfigManager.getJVMOptions(this.server.id)) // Main Java Class args.push(this.forgeData.mainClass) @@ -601,62 +606,19 @@ export default class ProcessBuilder { args = args.concat(this.versionData.arguments.game) for (let i = 0; i < args.length; i++) { - if (typeof args[i] === 'object' && args[i].rules != null) { + const argument = args[i]; + if (typeof argument === 'string') { - let checksum = 0 - for (let rule of args[i].rules) { - if (rule.os != null) { - if (rule.os.name === Library.mojangFriendlyOS() - && (rule.os.version == null || new RegExp(rule.os.version).test(os.release()))) { - if (rule.action === 'allow') { - checksum++ - } - } else { - if (rule.action === 'disallow') { - checksum++ - } - } - } else if (rule.features != null) { - // We don't have many 'features' in the index at the moment. - // This should be fine for a while. - if (rule.features.has_custom_resolution != null && rule.features.has_custom_resolution === true) { - if (ConfigManager.getFullscreen()) { - args[i].value = [ - '--fullscreen', - 'true' - ] - } - checksum++ - } - } - } - - // TODO splice not push - if (checksum === args[i].rules.length) { - if (typeof args[i].value === 'string') { - args[i] = args[i].value - } else if (typeof args[i].value === 'object') { - //args = args.concat(args[i].value) - args.splice(i, 1, ...args[i].value) - } - - // Decrement i to reprocess the resolved value - i-- - } else { - args[i] = null - } - - } else if (typeof args[i] === 'string') { - if (argDiscovery.test(args[i])) { - const identifier = args[i].match(argDiscovery)[1] - let val = null + if (argDiscovery.test(argument)) { + const identifier = argument.match(argDiscovery)![1] + let val: string | null = null; switch (identifier) { case 'auth_player_name': val = this.authUser.displayName.trim() break case 'version_name': //val = versionData.id - val = this.server.getID() + val = this.server.id break case 'game_directory': val = this.gameDir @@ -680,28 +642,73 @@ export default class ProcessBuilder { val = this.versionData.type break case 'resolution_width': - val = ConfigManager.getGameWidth() + val = ConfigManager.getGameWidth().toString(); break case 'resolution_height': - val = ConfigManager.getGameHeight() + val = ConfigManager.getGameHeight().toString(); break case 'natives_directory': - val = args[i].replace(argDiscovery, tempNativePath) + val = argument.replace(argDiscovery, tempNativePath) break case 'launcher_name': - val = args[i].replace(argDiscovery, ConfigManager.launcherName) + val = argument.replace(argDiscovery, ConfigManager.launcherName) break case 'launcher_version': - val = args[i].replace(argDiscovery, this.launcherVersion) + val = argument.replace(argDiscovery, this.launcherVersion) break case 'classpath': val = this.classpathArg(mods, tempNativePath).join(ProcessBuilder.classpathSeparator) break } - if (val != null) { + if (val) { args[i] = val } } + + } else if (argument.rules != null) { + let checksum = 0 + for (let rule of argument.rules) { + if (rule.os != null) { + if (rule.os.name === Library.mojangFriendlyOS() + && (rule.os.version == null || new RegExp(rule.os.version).test(os.release()))) { + if (rule.action === 'allow') { + checksum++ + } + } else { + if (rule.action === 'disallow') { + checksum++ + } + } + } else if (rule.features != null) { + // We don't have many 'features' in the index at the moment. + // This should be fine for a while. + // TODO: Make it a bit better + if (rule.features.has_custom_resolution != null && rule.features.has_custom_resolution === true) { + if (ConfigManager.getFullscreen()) { + (args[i] as ArgumentRule).value = [ + '--fullscreen', + 'true' + ] + } + checksum++ + } + } + } + + // TODO splice not push + if (checksum === argument.rules.length) { + if (typeof argument.value === 'string') { + args[i] = argument.value + } else if (typeof argument.value === 'object') { + args.splice(i, 1, ...argument.value) + } + + // Decrement i to reprocess the resolved value + i--; + } else { + // If not whith the checksum remove the element. + args.splice(i, 1) + } } } @@ -727,11 +734,9 @@ export default class ProcessBuilder { args = args.concat(this.forgeData.arguments.game) // Filter null values - args = args.filter(arg => { - return arg != null - }) + args = args.filter(arg => typeof arg === 'string') - return args + return args as string[] } private lteMinorVersion(version: number) { @@ -782,14 +787,14 @@ export default class ProcessBuilder { for (let i = 0; i < mcArgs.length; ++i) { if (argDiscovery.test(mcArgs[i])) { const identifier = mcArgs[i].match(argDiscovery)[1] - let val = null + let val: string | null = null switch (identifier) { case 'auth_player_name': val = this.authUser.displayName.trim() break case 'version_name': //val = versionData.id - val = this.server.getID() + val = this.server.id break case 'game_directory': val = this.gameDir @@ -858,10 +863,11 @@ export default class ProcessBuilder { return mcArgs } - - private processAutoConnectArg(args: string[]) { - if (ConfigManager.getAutoConnect() && this.server.isAutoConnect()) { - const serverURL = new URL('my://' + this.server.getAddress()) + //TODO: Not a huge fan of working by reference in Typescript/JS + // Can be a bit shitty some times + private processAutoConnectArg(args: any[]) { + if (ConfigManager.getAutoConnect() && this.server.autoconnect) { + const serverURL = new URL('my://' + this.server.address) args.push('--server') args.push(serverURL.hostname) if (serverURL.port) { diff --git a/src/util/DevUtil.ts b/src/util/DevUtil.ts new file mode 100644 index 00000000..d8f0bc88 --- /dev/null +++ b/src/util/DevUtil.ts @@ -0,0 +1,18 @@ +const getFromEnv = parseInt(process.env.ELECTRON_IS_DEV ?? "", 10) === 1 +const isEnvSet = 'ELECTRON_IS_DEV' in process.env + +export class DevUtil { + private static enforceDevMode = false; + + public static get IsDev() { + //@ts-ignore + return this.enforceDevMode ?? isEnvSet ? getFromEnv : (process.defaultApp || /node_modules[\\/]electron[\\/]/.test(process.execPath)) + } + + public static set IsDev(value) { + this.enforceDevMode = value; + } + + public static get isARM64() { return process.arch === 'arm64' } + +} \ No newline at end of file diff --git a/src/util/JavaType.ts b/src/util/JavaType.ts new file mode 100644 index 00000000..560da8f5 --- /dev/null +++ b/src/util/JavaType.ts @@ -0,0 +1,49 @@ +export type AdoptiumBinary = { + binary: { + architecture: string, + download_count: number, + heap_size: string, + image_type: string, + jvm_impl: string, + os: string, + package: { + checksum: string, + checksum_link: string, + download_count: number, + link: string, + metadata_link: string, + name: string, + signature_link: string, + size: number + }, + project: string, + scm_ref: string, + updated_at: string + } + release_link: string, + release_name: string, + vendor: string, + version: { + build: number, + major: number, + minor: number, + openjdk_version: string, + security: number, + semver: string + } +} + +export type JavaRuntimeVersion = { + build: number, + major: number, + minor?: number, + revision?: number, + update?: number, + execPath?: string, +} + +export type JavaMetaObject = { + execPath?: string, + version: JavaRuntimeVersion, + isARM?: boolean, +} \ No newline at end of file diff --git a/src/util/MinecraftUtil.ts b/src/util/MinecraftUtil.ts index 2bc65899..e7b5096b 100644 --- a/src/util/MinecraftUtil.ts +++ b/src/util/MinecraftUtil.ts @@ -21,14 +21,10 @@ export class MinecraftUtil { public static isForgeGradle3(mcVersion: string, forgeVersion: string) { - if (this.mcVersionAtLeast('1.13', mcVersion)) { - return true - } + if (this.mcVersionAtLeast('1.13', mcVersion)) return true; try { - const forgeVer = forgeVersion.split('-')[1] - const maxFG2 = [14, 23, 5, 2847] const verSplit = forgeVer.split('.').map(v => Number(v)) @@ -39,16 +35,13 @@ export class MinecraftUtil { return false } } - return false - } catch (err) { throw new Error('Forge version is complex (changed).. launcher requires a patch.') } } public static isAutoconnectBroken(forgeVersion: string) { - const minWorking = [31, 2, 15] const verSplit = forgeVersion.split('.').map(v => Number(v))