mirror of
https://github.com/artegoser/pimi-launcher-core.git
synced 2024-11-22 04:06:21 +03:00
ForgeWrapper support, ESLint added
This commit is contained in:
parent
9e21e8189c
commit
99b7ff2367
9 changed files with 2446 additions and 767 deletions
21
.eslintrc.json
Normal file
21
.eslintrc.json
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"commonjs": true,
|
||||||
|
"es6": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"globals": {
|
||||||
|
"Atomics": "readonly",
|
||||||
|
"SharedArrayBuffer": "readonly"
|
||||||
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2018
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"camelcase": "off",
|
||||||
|
"no-template-curly-in-string": "off"
|
||||||
|
}
|
||||||
|
}
|
14
README.md
14
README.md
|
@ -72,7 +72,7 @@ launcher.on('data', (e) => console.log(e));
|
||||||
| `options.version.custom` | String | The name of the folder, jar file, and version json in the version folder. | False |
|
| `options.version.custom` | String | The name of the folder, jar file, and version json in the version folder. | False |
|
||||||
| `options.memory.max` | String | Max amount of memory being used by Minecraft. | True |
|
| `options.memory.max` | String | Max amount of memory being used by Minecraft. | True |
|
||||||
| `options.memory.min` | String | Min amount of memory being used by Minecraft. | True |
|
| `options.memory.min` | String | Min amount of memory being used by Minecraft. | True |
|
||||||
| `options.forge` | String | Path to Universal Forge Jar. (Only for versions below 1.13+ | False |
|
| `options.forge` | String | Path to Forge Jar. (Versions below 1.13 should be the "universal" jar while versions above 1.13+ should be the "installer" jar)| False |
|
||||||
| `options.javaPath` | String | Path to the JRE executable file, will default to `java` if not entered. | False |
|
| `options.javaPath` | String | Path to the JRE executable file, will default to `java` if not entered. | False |
|
||||||
| `options.server.host` | String | Host url to the server, don't include the port. | False |
|
| `options.server.host` | String | Host url to the server, don't include the port. | False |
|
||||||
| `options.server.port` | String | Port of the host url, will default to `25565` if not entered. | False |
|
| `options.server.port` | String | Port of the host url, will default to `25565` if not entered. | False |
|
||||||
|
@ -118,16 +118,8 @@ let opts = {
|
||||||
##### Custom
|
##### Custom
|
||||||
If you are loading up a client outside of vanilla Minecraft or Forge (Optifine and for an example), you'll need to download the needed files yourself if you don't provide downloads url downloads like Forge and Fabric. If no version jar is specified, MCLC will default back to the normal MC jar so mods like Fabric work.
|
If you are loading up a client outside of vanilla Minecraft or Forge (Optifine and for an example), you'll need to download the needed files yourself if you don't provide downloads url downloads like Forge and Fabric. If no version jar is specified, MCLC will default back to the normal MC jar so mods like Fabric work.
|
||||||
##### Installer
|
##### Installer
|
||||||
You'll need to provide the folder created in the versions if you're running the new forge like so
|
This runs an executable with specified launch arguments. Was used to support Forge 1.13 before ForgeWrapper.
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": {
|
|
||||||
"number": "1.14.2",
|
|
||||||
"type": "release",
|
|
||||||
"custom": "1.14.2-forge-26.0.63"
|
|
||||||
},
|
|
||||||
"installer": "forge-1.14.2-26.0.63-installer.jar"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
#### Authenticator Functions
|
#### Authenticator Functions
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
const request = require('request');
|
const request = require('request')
|
||||||
const uuid = require('uuid/v1');
|
const uuid = require('uuid/v1')
|
||||||
let api_url = "https://authserver.mojang.com";
|
let api_url = 'https://authserver.mojang.com'
|
||||||
|
|
||||||
module.exports.getAuth = function (username, password) {
|
module.exports.getAuth = function (username, password) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
@ -11,16 +11,16 @@ module.exports.getAuth = function (username, password) {
|
||||||
uuid: uuid(),
|
uuid: uuid(),
|
||||||
name: username,
|
name: username,
|
||||||
user_properties: JSON.stringify({})
|
user_properties: JSON.stringify({})
|
||||||
};
|
}
|
||||||
|
|
||||||
return resolve(user);
|
return resolve(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestObject = {
|
const requestObject = {
|
||||||
url: api_url + "/authenticate",
|
url: api_url + '/authenticate',
|
||||||
json: {
|
json: {
|
||||||
agent: {
|
agent: {
|
||||||
name: "Minecraft",
|
name: 'Minecraft',
|
||||||
version: 1
|
version: 1
|
||||||
},
|
},
|
||||||
username: username,
|
username: username,
|
||||||
|
@ -28,12 +28,12 @@ module.exports.getAuth = function (username, password) {
|
||||||
clientToken: uuid(),
|
clientToken: uuid(),
|
||||||
requestUser: true
|
requestUser: true
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
request.post(requestObject, function (error, response, body) {
|
request.post(requestObject, function (error, response, body) {
|
||||||
if (error) return reject(error);
|
if (error) return reject(error)
|
||||||
if (!body || !body.selectedProfile) {
|
if (!body || !body.selectedProfile) {
|
||||||
return reject("Validation error: " + response.statusMessage);
|
return reject(new Error('Validation error: ' + response.statusMessage))
|
||||||
}
|
}
|
||||||
|
|
||||||
const userProfile = {
|
const userProfile = {
|
||||||
|
@ -43,48 +43,48 @@ module.exports.getAuth = function (username, password) {
|
||||||
name: body.selectedProfile.name,
|
name: body.selectedProfile.name,
|
||||||
selected_profile: body.selectedProfile,
|
selected_profile: body.selectedProfile,
|
||||||
user_properties: JSON.stringify(body.user.properties || {})
|
user_properties: JSON.stringify(body.user.properties || {})
|
||||||
};
|
}
|
||||||
|
|
||||||
resolve(userProfile);
|
resolve(userProfile)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
module.exports.validate = function (access_token, client_token) {
|
module.exports.validate = function (access_token, client_token) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const requestObject = {
|
const requestObject = {
|
||||||
url: api_url + "/validate",
|
url: api_url + '/validate',
|
||||||
json: {
|
json: {
|
||||||
"accessToken": access_token,
|
accessToken: access_token,
|
||||||
"clientToken": client_token
|
clientToken: client_token
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
request.post(requestObject, async function (error, response, body) {
|
request.post(requestObject, async function (error, response, body) {
|
||||||
if (error) return reject(error);
|
if (error) return reject(error)
|
||||||
|
|
||||||
if (!body) resolve(true);
|
if (!body) resolve(true)
|
||||||
else reject(body);
|
else reject(body)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
module.exports.refreshAuth = function (accessToken, clientToken, selectedProfile) {
|
module.exports.refreshAuth = function (accessToken, clientToken, selectedProfile) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const requestObject = {
|
const requestObject = {
|
||||||
url: api_url + "/refresh",
|
url: api_url + '/refresh',
|
||||||
json: {
|
json: {
|
||||||
"accessToken": accessToken,
|
accessToken: accessToken,
|
||||||
"clientToken": clientToken,
|
clientToken: clientToken,
|
||||||
"selectedProfile": selectedProfile,
|
selectedProfile: selectedProfile,
|
||||||
"requestUser": true
|
requestUser: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
request.post(requestObject, function (error, response, body) {
|
request.post(requestObject, function (error, response, body) {
|
||||||
if (error) return reject(error);
|
if (error) return reject(error)
|
||||||
if (!body || !body.selectedProfile) {
|
if (!body || !body.selectedProfile) {
|
||||||
return reject("Validation error: " + response.statusMessage);
|
return reject(new Error('Validation error: ' + response.statusMessage))
|
||||||
}
|
}
|
||||||
|
|
||||||
const userProfile = {
|
const userProfile = {
|
||||||
|
@ -93,51 +93,51 @@ module.exports.refreshAuth = function (accessToken, clientToken, selectedProfile
|
||||||
uuid: body.selectedProfile.id,
|
uuid: body.selectedProfile.id,
|
||||||
name: body.selectedProfile.name,
|
name: body.selectedProfile.name,
|
||||||
user_properties: JSON.stringify(body.user.properties || {})
|
user_properties: JSON.stringify(body.user.properties || {})
|
||||||
};
|
}
|
||||||
|
|
||||||
resolve(userProfile);
|
resolve(userProfile)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
module.exports.invalidate = function (accessToken, clientToken) {
|
module.exports.invalidate = function (accessToken, clientToken) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const requestObject = {
|
const requestObject = {
|
||||||
url: api_url + "/invalidate",
|
url: api_url + '/invalidate',
|
||||||
json: {
|
json: {
|
||||||
"accessToken": accessToken,
|
accessToken: accessToken,
|
||||||
"clientToken": clientToken
|
clientToken: clientToken
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
request.post(requestObject, function (error, response, body) {
|
request.post(requestObject, function (error, response, body) {
|
||||||
if (error) return reject(error);
|
if (error) return reject(error)
|
||||||
|
|
||||||
if (!body) resolve(true);
|
if (!body) resolve(true)
|
||||||
else reject(body);
|
else reject(body)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
module.exports.signOut = function (username, password) {
|
module.exports.signOut = function (username, password) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const requestObject = {
|
const requestObject = {
|
||||||
url: api_url + "/signout",
|
url: api_url + '/signout',
|
||||||
json: {
|
json: {
|
||||||
"username": username,
|
username: username,
|
||||||
"password": password
|
password: password
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
request.post(requestObject, function (error, response, body) {
|
request.post(requestObject, function (error, response, body) {
|
||||||
if (error) return reject(error);
|
if (error) return reject(error)
|
||||||
|
|
||||||
if (!body) resolve(true);
|
if (!body) resolve(true)
|
||||||
else reject(body);
|
else reject(body)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
module.exports.changeApiUrl = function (url) {
|
module.exports.changeApiUrl = function (url) {
|
||||||
api_url = url;
|
api_url = url
|
||||||
}
|
}
|
BIN
components/fw.jar
Normal file
BIN
components/fw.jar
Normal file
Binary file not shown.
|
@ -1,21 +1,21 @@
|
||||||
const fs = require('fs');
|
const fs = require('fs')
|
||||||
const shelljs = require('shelljs');
|
const shelljs = require('shelljs')
|
||||||
const path = require('path');
|
const path = require('path')
|
||||||
const request = require('request');
|
const request = require('request')
|
||||||
const checksum = require('checksum');
|
const checksum = require('checksum')
|
||||||
const zip = require('adm-zip');
|
const Zip = require('adm-zip')
|
||||||
const child = require('child_process');
|
const child = require('child_process')
|
||||||
let counter = 0;
|
let counter = 0
|
||||||
|
|
||||||
class Handler {
|
class Handler {
|
||||||
constructor (client) {
|
constructor (client) {
|
||||||
this.client = client;
|
this.client = client
|
||||||
this.options = client.options;
|
this.options = client.options
|
||||||
this.version = undefined;
|
this.version = undefined
|
||||||
this.baseRequest = request.defaults({
|
this.baseRequest = request.defaults({
|
||||||
pool: { maxSockets: this.options.overrides.maxSockets || 2 },
|
pool: { maxSockets: this.options.overrides.maxSockets || 2 },
|
||||||
timeout: this.options.timeout || 10000
|
timeout: this.options.timeout || 10000
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
checkJava (java) {
|
checkJava (java) {
|
||||||
|
@ -27,192 +27,189 @@ class Handler {
|
||||||
message: error
|
message: error
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.client.emit('debug', `[MCLC]: Using Java version ${stderr.match(/"(.*?)"/).pop()} ${stderr.includes('64-Bit') ? '64-bit': '32-Bit'}`);
|
this.client.emit('debug', `[MCLC]: Using Java version ${stderr.match(/"(.*?)"/).pop()} ${stderr.includes('64-Bit') ? '64-bit' : '32-Bit'}`)
|
||||||
resolve({
|
resolve({
|
||||||
run: true
|
run: true
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadAsync (url, directory, name, retry, type) {
|
downloadAsync (url, directory, name, retry, type) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
shelljs.mkdir('-p', directory);
|
shelljs.mkdir('-p', directory)
|
||||||
|
|
||||||
const _request = this.baseRequest(url);
|
const _request = this.baseRequest(url)
|
||||||
|
|
||||||
let received_bytes = 0;
|
let receivedBytes = 0
|
||||||
let total_bytes = 0;
|
let totalBytes = 0
|
||||||
|
|
||||||
_request.on('response', (data) => {
|
_request.on('response', (data) => {
|
||||||
if (data.statusCode === 404) {
|
if (data.statusCode === 404) {
|
||||||
this.client.emit('debug', `[MCLC]: Failed to download ${url} due to: File not found...`);
|
this.client.emit('debug', `[MCLC]: Failed to download ${url} due to: File not found...`)
|
||||||
resolve(false);
|
resolve(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
total_bytes = parseInt(data.headers['content-length']);
|
totalBytes = parseInt(data.headers['content-length'])
|
||||||
});
|
})
|
||||||
|
|
||||||
_request.on('error', async (error) => {
|
_request.on('error', async (error) => {
|
||||||
this.client.emit('debug', `[MCLC]: Failed to download asset to ${path.join(directory, name)} due to\n${error}.` +
|
this.client.emit('debug', `[MCLC]: Failed to download asset to ${path.join(directory, name)} due to\n${error}.` +
|
||||||
` Retrying... ${retry}`);
|
` Retrying... ${retry}`)
|
||||||
if (retry) await this.downloadAsync(url, directory, name, false, type);
|
if (retry) await this.downloadAsync(url, directory, name, false, type)
|
||||||
resolve();
|
resolve()
|
||||||
});
|
})
|
||||||
|
|
||||||
_request.on('data', (data) => {
|
_request.on('data', (data) => {
|
||||||
received_bytes += data.length;
|
receivedBytes += data.length
|
||||||
this.client.emit('download-status', {
|
this.client.emit('download-status', {
|
||||||
"name": name,
|
name: name,
|
||||||
"type": type,
|
type: type,
|
||||||
"current": received_bytes,
|
current: receivedBytes,
|
||||||
"total": total_bytes
|
total: totalBytes
|
||||||
|
})
|
||||||
})
|
})
|
||||||
});
|
|
||||||
|
|
||||||
const file = fs.createWriteStream(path.join(directory, name));
|
const file = fs.createWriteStream(path.join(directory, name))
|
||||||
_request.pipe(file);
|
_request.pipe(file)
|
||||||
|
|
||||||
file.once('finish', () => {
|
file.once('finish', () => {
|
||||||
this.client.emit('download', name);
|
this.client.emit('download', name)
|
||||||
resolve({
|
resolve({
|
||||||
failed: false,
|
failed: false,
|
||||||
asset: null
|
asset: null
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
file.on('error', async (e) => {
|
file.on('error', async (e) => {
|
||||||
this.client.emit('debug', `[MCLC]: Failed to download asset to ${path.join(directory, name)} due to\n${e}.` +
|
this.client.emit('debug', `[MCLC]: Failed to download asset to ${path.join(directory, name)} due to\n${e}.` +
|
||||||
` Retrying... ${retry}`);
|
` Retrying... ${retry}`)
|
||||||
if (fs.existsSync(path.join(directory, name))) shelljs.rm(path.join(directory, name));
|
if (fs.existsSync(path.join(directory, name))) shelljs.rm(path.join(directory, name))
|
||||||
if (retry) await this.downloadAsync(url, directory, name, false, type);
|
if (retry) await this.downloadAsync(url, directory, name, false, type)
|
||||||
resolve();
|
resolve()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
checkSum (hash, file) {
|
checkSum (hash, file) {
|
||||||
return new Promise(resolve => {
|
return new Promise((resolve, reject) => {
|
||||||
checksum.file(file, (err, sum) => resolve(hash === sum));
|
checksum.file(file, (err, sum) => {
|
||||||
});
|
err
|
||||||
|
? reject(new Error(err))
|
||||||
|
: resolve(hash === sum)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getVersion () {
|
getVersion () {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const versionJsonPath = this.options.overrides.versionJson || path.join(this.options.directory, `${this.options.version.number}.json`);
|
const versionJsonPath = this.options.overrides.versionJson || path.join(this.options.directory, `${this.options.version.number}.json`)
|
||||||
if (fs.existsSync(versionJsonPath)) {
|
if (fs.existsSync(versionJsonPath)) {
|
||||||
this.version = JSON.parse(fs.readFileSync(versionJsonPath));
|
this.version = JSON.parse(fs.readFileSync(versionJsonPath))
|
||||||
resolve(this.version);
|
resolve(this.version)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = `${this.options.overrides.url.meta}/mc/game/version_manifest.json`;
|
const manifest = `${this.options.overrides.url.meta}/mc/game/version_manifest.json`
|
||||||
request.get(manifest, (error, response, body) => {
|
request.get(manifest, (error, response, body) => {
|
||||||
if (error) resolve(error);
|
if (error) resolve(error)
|
||||||
|
|
||||||
const parsed = JSON.parse(body);
|
const parsed = JSON.parse(body)
|
||||||
|
|
||||||
for (const desiredVersion in parsed.versions) {
|
for (const desiredVersion in parsed.versions) {
|
||||||
if (parsed.versions[desiredVersion].id === this.options.version.number) {
|
if (parsed.versions[desiredVersion].id === this.options.version.number) {
|
||||||
request.get(parsed.versions[desiredVersion].url, (error, response, body) => {
|
request.get(parsed.versions[desiredVersion].url, (error, response, body) => {
|
||||||
if (error) resolve(error);
|
if (error) resolve(error)
|
||||||
|
|
||||||
this.client.emit('debug', `[MCLC]: Parsed version from version manifest`);
|
this.client.emit('debug', '[MCLC]: Parsed version from version manifest')
|
||||||
this.version = JSON.parse(body);
|
this.version = JSON.parse(body)
|
||||||
resolve(this.version);
|
resolve(this.version)
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getJar() {
|
async getJar () {
|
||||||
return new Promise(async (resolve) => {
|
await this.downloadAsync(this.version.downloads.client.url, this.options.directory, `${this.options.version.number}.jar`, true, 'version-jar')
|
||||||
await this.downloadAsync(this.version.downloads.client.url, this.options.directory, `${this.options.version.number}.jar`, true, 'version-jar');
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(this.options.directory, `${this.options.version.number}.json`), JSON.stringify(this.version, null, 4));
|
fs.writeFileSync(path.join(this.options.directory, `${this.options.version.number}.json`), JSON.stringify(this.version, null, 4))
|
||||||
|
|
||||||
this.client.emit('debug', '[MCLC]: Downloaded version jar and wrote version json');
|
return this.client.emit('debug', '[MCLC]: Downloaded version jar and wrote version json')
|
||||||
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAssets() {
|
async getAssets () {
|
||||||
return new Promise(async(resolve) => {
|
|
||||||
if (!fs.existsSync(path.join(this.options.root, 'assets', 'indexes', `${this.version.assetIndex.id}.json`))) {
|
if (!fs.existsSync(path.join(this.options.root, 'assets', 'indexes', `${this.version.assetIndex.id}.json`))) {
|
||||||
await this.downloadAsync(this.version.assetIndex.url, path.join(this.options.root, 'assets', 'indexes'),
|
await this.downloadAsync(this.version.assetIndex.url, path.join(this.options.root, 'assets', 'indexes'),
|
||||||
`${this.version.assetIndex.id}.json`, true, 'asset-json');
|
`${this.version.assetIndex.id}.json`, true, 'asset-json')
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = JSON.parse(fs.readFileSync(path.join(this.options.root, 'assets', 'indexes',`${this.version.assetIndex.id}.json`), { encoding: 'utf8' }));
|
const index = JSON.parse(fs.readFileSync(path.join(this.options.root, 'assets', 'indexes', `${this.version.assetIndex.id}.json`), { encoding: 'utf8' }))
|
||||||
|
|
||||||
this.client.emit('progress', {
|
this.client.emit('progress', {
|
||||||
type: 'assets',
|
type: 'assets',
|
||||||
task: 0,
|
task: 0,
|
||||||
total: Object.keys(index.objects).length
|
total: Object.keys(index.objects).length
|
||||||
});
|
})
|
||||||
|
|
||||||
await Promise.all(Object.keys(index.objects).map(async asset => {
|
await Promise.all(Object.keys(index.objects).map(async asset => {
|
||||||
const hash = index.objects[asset].hash;
|
const hash = index.objects[asset].hash
|
||||||
const subhash = hash.substring(0,2);
|
const subhash = hash.substring(0, 2)
|
||||||
const assetDirectory = this.options.overrides.assetRoot || path.join(this.options.root, 'assets');
|
const assetDirectory = this.options.overrides.assetRoot || path.join(this.options.root, 'assets')
|
||||||
const subAsset = path.join(assetDirectory, 'objects', subhash);
|
const subAsset = path.join(assetDirectory, 'objects', subhash)
|
||||||
|
|
||||||
if (!fs.existsSync(path.join(subAsset, hash)) || !await this.checkSum(hash, path.join(subAsset, hash))) {
|
if (!fs.existsSync(path.join(subAsset, hash)) || !await this.checkSum(hash, path.join(subAsset, hash))) {
|
||||||
await this.downloadAsync(`${this.options.overrides.url.resource}/${subhash}/${hash}`, subAsset, hash,
|
await this.downloadAsync(`${this.options.overrides.url.resource}/${subhash}/${hash}`, subAsset, hash,
|
||||||
true, 'assets');
|
true, 'assets')
|
||||||
counter = counter + 1;
|
counter = counter + 1
|
||||||
this.client.emit('progress', {
|
this.client.emit('progress', {
|
||||||
type: 'assets',
|
type: 'assets',
|
||||||
task: counter,
|
task: counter,
|
||||||
total: Object.keys(index.objects).length
|
total: Object.keys(index.objects).length
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}));
|
}))
|
||||||
counter = 0;
|
counter = 0
|
||||||
|
|
||||||
// Copy assets to legacy if it's an older Minecraft version.
|
// Copy assets to legacy if it's an older Minecraft version.
|
||||||
if(this.version.assets === "legacy" || this.version.assets === "pre-1.6") {
|
if (this.version.assets === 'legacy' || this.version.assets === 'pre-1.6') {
|
||||||
const assetDirectory = this.options.overrides.assetRoot || path.join(this.options.root, 'assets');
|
const assetDirectory = this.options.overrides.assetRoot || path.join(this.options.root, 'assets')
|
||||||
this.client.emit('debug', `[MCLC]: Copying assets over to ${path.join(assetDirectory, 'legacy')}`);
|
this.client.emit('debug', `[MCLC]: Copying assets over to ${path.join(assetDirectory, 'legacy')}`)
|
||||||
|
|
||||||
this.client.emit('progress', {
|
this.client.emit('progress', {
|
||||||
type: 'assets-copy',
|
type: 'assets-copy',
|
||||||
task: 0,
|
task: 0,
|
||||||
total: Object.keys(index.objects).length
|
total: Object.keys(index.objects).length
|
||||||
});
|
})
|
||||||
|
|
||||||
await Promise.all(Object.keys(index.objects).map(async asset => {
|
await Promise.all(Object.keys(index.objects).map(async asset => {
|
||||||
const hash = index.objects[asset].hash;
|
const hash = index.objects[asset].hash
|
||||||
const subhash = hash.substring(0,2);
|
const subhash = hash.substring(0, 2)
|
||||||
const subAsset = path.join(assetDirectory, 'objects', subhash);
|
const subAsset = path.join(assetDirectory, 'objects', subhash)
|
||||||
|
|
||||||
let legacyAsset = asset.split('/');
|
const legacyAsset = asset.split('/')
|
||||||
legacyAsset.pop();
|
legacyAsset.pop()
|
||||||
|
|
||||||
if (!fs.existsSync(path.join(assetDirectory, 'legacy', legacyAsset.join('/')))) {
|
if (!fs.existsSync(path.join(assetDirectory, 'legacy', legacyAsset.join('/')))) {
|
||||||
shelljs.mkdir('-p', path.join(assetDirectory, 'legacy', legacyAsset.join('/')));
|
shelljs.mkdir('-p', path.join(assetDirectory, 'legacy', legacyAsset.join('/')))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(path.join(assetDirectory, 'legacy', asset))) {
|
if (!fs.existsSync(path.join(assetDirectory, 'legacy', asset))) {
|
||||||
fs.copyFileSync(path.join(subAsset, hash), path.join(assetDirectory, 'legacy', asset))
|
fs.copyFileSync(path.join(subAsset, hash), path.join(assetDirectory, 'legacy', asset))
|
||||||
}
|
}
|
||||||
counter = counter + 1;
|
counter = counter + 1
|
||||||
this.client.emit('progress', {
|
this.client.emit('progress', {
|
||||||
type: 'assets-copy',
|
type: 'assets-copy',
|
||||||
task: counter,
|
task: counter,
|
||||||
total: Object.keys(index.objects).length
|
total: Object.keys(index.objects).length
|
||||||
})
|
})
|
||||||
}));
|
}))
|
||||||
}
|
}
|
||||||
counter = 0;
|
counter = 0
|
||||||
|
|
||||||
this.client.emit('debug', '[MCLC]: Downloaded assets');
|
this.client.emit('debug', '[MCLC]: Downloaded assets')
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parseRule (lib) {
|
parseRule (lib) {
|
||||||
|
@ -221,263 +218,268 @@ class Handler {
|
||||||
if (lib.rules[0].action === 'allow' &&
|
if (lib.rules[0].action === 'allow' &&
|
||||||
lib.rules[1].action === 'disallow' &&
|
lib.rules[1].action === 'disallow' &&
|
||||||
lib.rules[1].os.name === 'osx') {
|
lib.rules[1].os.name === 'osx') {
|
||||||
return this.getOS() === 'osx';
|
return this.getOS() === 'osx'
|
||||||
} else {
|
} else {
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (lib.rules[0].action === 'allow' && lib.rules[0].os) return this.getOS() !== 'osx';
|
if (lib.rules[0].action === 'allow' && lib.rules[0].os) return this.getOS() !== 'osx'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getNatives() {
|
async getNatives () {
|
||||||
return new Promise(async(resolve) => {
|
const nativeDirectory = this.options.overrides.natives || path.join(this.options.root, 'natives', this.version.id)
|
||||||
const nativeDirectory = this.options.overrides.natives || path.join(this.options.root, 'natives', this.version.id);
|
|
||||||
|
|
||||||
if (!fs.existsSync(nativeDirectory) || !fs.readdirSync(nativeDirectory).length) {
|
if (!fs.existsSync(nativeDirectory) || !fs.readdirSync(nativeDirectory).length) {
|
||||||
shelljs.mkdir('-p', nativeDirectory);
|
shelljs.mkdir('-p', nativeDirectory)
|
||||||
|
|
||||||
const natives = () => {
|
const natives = async () => {
|
||||||
return new Promise(async resolve => {
|
const natives = []
|
||||||
const natives = [];
|
|
||||||
await Promise.all(this.version.libraries.map(async (lib) => {
|
await Promise.all(this.version.libraries.map(async (lib) => {
|
||||||
if (!lib.downloads.classifiers) return;
|
if (!lib.downloads.classifiers) return
|
||||||
if (this.parseRule(lib)) return;
|
if (this.parseRule(lib)) return
|
||||||
|
|
||||||
const native = this.getOS() === 'osx' ?
|
const native = this.getOS() === 'osx'
|
||||||
lib.downloads.classifiers['natives-osx'] || lib.downloads.classifiers['natives-macos'] :
|
? lib.downloads.classifiers['natives-osx'] || lib.downloads.classifiers['natives-macos']
|
||||||
lib.downloads.classifiers[`natives-${this.getOS()}`];
|
: lib.downloads.classifiers[`natives-${this.getOS()}`]
|
||||||
|
|
||||||
natives.push(native);
|
natives.push(native)
|
||||||
}));
|
}))
|
||||||
resolve (natives);
|
return natives
|
||||||
})
|
}
|
||||||
};
|
const stat = await natives()
|
||||||
const stat = await natives();
|
|
||||||
|
|
||||||
this.client.emit('progress', {
|
this.client.emit('progress', {
|
||||||
type: 'natives',
|
type: 'natives',
|
||||||
task: 0,
|
task: 0,
|
||||||
total: stat.length
|
total: stat.length
|
||||||
});
|
})
|
||||||
|
|
||||||
await Promise.all(stat.map(async (native) => {
|
await Promise.all(stat.map(async (native) => {
|
||||||
// Edge case on some systems where native is undefined and throws an error, this should fix it.
|
// Edge case on some systems where native is undefined and throws an error, this should fix it.
|
||||||
if(!native) return;
|
if (!native) return
|
||||||
const name = native.path.split('/').pop();
|
const name = native.path.split('/').pop()
|
||||||
await this.downloadAsync(native.url, nativeDirectory, name, true, 'natives');
|
await this.downloadAsync(native.url, nativeDirectory, name, true, 'natives')
|
||||||
if (!await this.checkSum(native.sha1, path.join(nativeDirectory, name))) {
|
if (!await this.checkSum(native.sha1, path.join(nativeDirectory, name))) {
|
||||||
await this.downloadAsync(native.url, nativeDirectory, name, true, 'natives');
|
await this.downloadAsync(native.url, nativeDirectory, name, true, 'natives')
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
new zip(path.join(nativeDirectory, name)).extractAllTo(nativeDirectory, true);
|
new Zip(path.join(nativeDirectory, name)).extractAllTo(nativeDirectory, true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Only doing a console.warn since a stupid error happens. You can basically ignore this.
|
// Only doing a console.warn since a stupid error happens. You can basically ignore this.
|
||||||
// if it says Invalid file name, just means two files were downloaded and both were deleted.
|
// if it says Invalid file name, just means two files were downloaded and both were deleted.
|
||||||
// All is well.
|
// All is well.
|
||||||
console.warn(e);
|
console.warn(e)
|
||||||
}
|
}
|
||||||
shelljs.rm(path.join(nativeDirectory, name));
|
shelljs.rm(path.join(nativeDirectory, name))
|
||||||
counter = counter + 1;
|
counter = counter + 1
|
||||||
this.client.emit('progress', {
|
this.client.emit('progress', {
|
||||||
type: 'natives',
|
type: 'natives',
|
||||||
task: counter,
|
task: counter,
|
||||||
total: stat.length
|
total: stat.length
|
||||||
})
|
})
|
||||||
}));
|
}))
|
||||||
this.client.emit('debug', '[MCLC]: Downloaded and extracted natives');
|
this.client.emit('debug', '[MCLC]: Downloaded and extracted natives')
|
||||||
}
|
}
|
||||||
|
|
||||||
counter = 0;
|
counter = 0
|
||||||
this.client.emit('debug', `[MCLC]: Set native path to ${nativeDirectory}`);
|
this.client.emit('debug', `[MCLC]: Set native path to ${nativeDirectory}`)
|
||||||
resolve(nativeDirectory);
|
|
||||||
});
|
return nativeDirectory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not bothering to rewrite this.
|
||||||
async getForgeDependenciesLegacy () {
|
async getForgeDependenciesLegacy () {
|
||||||
if (!fs.existsSync(path.join(this.options.root, 'forge'))) {
|
if (!fs.existsSync(path.join(this.options.root, 'forge'))) {
|
||||||
shelljs.mkdir('-p', path.join(this.options.root, 'forge'));
|
shelljs.mkdir('-p', path.join(this.options.root, 'forge'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipFile = new Zip(this.options.forge)
|
||||||
|
|
||||||
|
if (zipFile.getEntry('install_profile.json')) {
|
||||||
|
this.client.emit('debug', '[MCLC]: Detected Forge installer, will treat as custom with ForgeWrapper')
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new zip(this.options.forge).extractEntryTo('version.json', path.join(this.options.root, 'forge', `${this.version.id}`), false, true);
|
await zipFile.extractEntryTo('version.json', path.join(this.options.root, 'forge', `${this.version.id}`), false, true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.client.emit('debug', `[MCLC]: Unable to extract version.json from the forge jar due to ${e}`);
|
this.client.emit('debug', `[MCLC]: Unable to extract version.json from the forge jar due to ${e}`)
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const forge = JSON.parse(fs.readFileSync(path.join(this.options.root, 'forge', `${this.version.id}`, 'version.json'), { encoding: 'utf8' }));
|
const forge = JSON.parse(fs.readFileSync(path.join(this.options.root, 'forge', `${this.version.id}`, 'version.json'), { encoding: 'utf8' }))
|
||||||
const paths = [];
|
const paths = []
|
||||||
|
|
||||||
this.client.emit('progress', {
|
this.client.emit('progress', {
|
||||||
type: 'forge',
|
type: 'forge',
|
||||||
task: 0,
|
task: 0,
|
||||||
total: forge.libraries.length
|
total: forge.libraries.length
|
||||||
});
|
})
|
||||||
|
|
||||||
await Promise.all(forge.libraries.map(async library => {
|
await Promise.all(forge.libraries.map(async library => {
|
||||||
const lib = library.name.split(':');
|
const lib = library.name.split(':')
|
||||||
|
|
||||||
if(lib[0] === 'net.minecraftforge' && lib[1].includes('forge')) return;
|
if (lib[0] === 'net.minecraftforge' && lib[1].includes('forge')) return
|
||||||
|
|
||||||
let url = this.options.overrides.url.mavenForge;
|
let url = this.options.overrides.url.mavenForge
|
||||||
const jarPath = path.join(this.options.root, 'libraries', `${lib[0].replace(/\./g, '/')}/${lib[1]}/${lib[2]}`);
|
const jarPath = path.join(this.options.root, 'libraries', `${lib[0].replace(/\./g, '/')}/${lib[1]}/${lib[2]}`)
|
||||||
const name = `${lib[1]}-${lib[2]}.jar`;
|
const name = `${lib[1]}-${lib[2]}.jar`
|
||||||
|
|
||||||
if (!library.url) {
|
if (!library.url) {
|
||||||
if (library.serverreq || library.clientreq) {
|
if (library.serverreq || library.clientreq) {
|
||||||
url = this.options.overrides.url.defaultRepoForge;
|
url = this.options.overrides.url.defaultRepoForge
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadLink = `${url}${lib[0].replace(/\./g, '/')}/${lib[1]}/${lib[2]}/${name}`;
|
const downloadLink = `${url}${lib[0].replace(/\./g, '/')}/${lib[1]}/${lib[2]}/${name}`
|
||||||
|
|
||||||
if (fs.existsSync(path.join(jarPath, name))) {
|
if (fs.existsSync(path.join(jarPath, name))) {
|
||||||
paths.push(`${jarPath}${path.sep}${name}`);
|
paths.push(`${jarPath}${path.sep}${name}`)
|
||||||
counter = counter + 1;
|
counter = counter + 1
|
||||||
this.client.emit('progress', { type: 'forge', task: counter, total: forge.libraries.length});
|
this.client.emit('progress', { type: 'forge', task: counter, total: forge.libraries.length })
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
if(!fs.existsSync(jarPath)) shelljs.mkdir('-p', jarPath);
|
if (!fs.existsSync(jarPath)) shelljs.mkdir('-p', jarPath)
|
||||||
|
|
||||||
const download = await this.downloadAsync(downloadLink, jarPath, name, true, 'forge');
|
const download = await this.downloadAsync(downloadLink, jarPath, name, true, 'forge')
|
||||||
if(!download) await this.downloadAsync(`${this.options.overrides.url.fallbackMaven}${lib[0].replace(/\./g, '/')}/${lib[1]}/${lib[2]}/${name}`, jarPath, name, true, 'forge');
|
if (!download) await this.downloadAsync(`${this.options.overrides.url.fallbackMaven}${lib[0].replace(/\./g, '/')}/${lib[1]}/${lib[2]}/${name}`, jarPath, name, true, 'forge')
|
||||||
|
|
||||||
paths.push(`${jarPath}${path.sep}${name}`);
|
paths.push(`${jarPath}${path.sep}${name}`)
|
||||||
counter = counter + 1;
|
counter = counter + 1
|
||||||
this.client.emit('progress', {
|
this.client.emit('progress', {
|
||||||
type: 'forge',
|
type: 'forge',
|
||||||
task: counter,
|
task: counter,
|
||||||
total: forge.libraries.length
|
total: forge.libraries.length
|
||||||
})
|
})
|
||||||
}));
|
}))
|
||||||
|
|
||||||
counter = 0;
|
counter = 0
|
||||||
this.client.emit('debug', '[MCLC]: Downloaded Forge dependencies');
|
this.client.emit('debug', '[MCLC]: Downloaded Forge dependencies')
|
||||||
|
|
||||||
return {paths, forge};
|
return { paths, forge }
|
||||||
|
}
|
||||||
|
|
||||||
|
getForgedWrapped () {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const launchArgs = `"${this.options.javaPath ? this.options.javaPath : 'java'}" -jar ${path.resolve(this.options.forgeWrapper.jar)}` +
|
||||||
|
` --installer=${this.options.forge} --instance=${this.options.root} ` +
|
||||||
|
`--saveTo=${path.join(this.options.root, 'libraries', 'io', 'github', 'zekerzhayard', 'ForgeWrapper', this.options.forgeWrapper.version)}`
|
||||||
|
const fw = child.exec(launchArgs)
|
||||||
|
|
||||||
|
fw.on('close', (e) => {
|
||||||
|
resolve(JSON.parse(fs.readFileSync(path.join(this.options.root, 'forge', this.version.id, 'version.json'), { encoding: 'utf8' })))
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
runInstaller (path) {
|
runInstaller (path) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const installer = child.exec(path);
|
const installer = child.exec(path)
|
||||||
installer.on('close', (code) => resolve());
|
installer.on('close', (code) => resolve())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getClasses() {
|
async downloadToDirectory (directory, libraries, eventName) {
|
||||||
return new Promise(async (resolve) => {
|
const libs = []
|
||||||
const libs = [];
|
|
||||||
|
|
||||||
if(this.options.version.custom) {
|
await Promise.all(libraries.map(async library => {
|
||||||
const customJarJson = JSON.parse(fs.readFileSync(path.join(this.options.root, 'versions', this.options.version.custom, `${this.options.version.custom}.json`), { encoding: 'utf8'}));
|
if (!library) return
|
||||||
|
const lib = library.name.split(':')
|
||||||
|
|
||||||
this.client.emit('progress', {
|
let jarPath
|
||||||
type: 'classes-custom',
|
let name
|
||||||
task: 0,
|
if (library.downloads && library.downloads.artifact && library.downloads.artifact.path) {
|
||||||
total: customJarJson.libraries.length
|
name = library.downloads.artifact.path.split('/')[library.downloads.artifact.path.split('/').length - 1]
|
||||||
});
|
jarPath = path.join(this.options.root, directory, this.popString(library.downloads.artifact.path))
|
||||||
|
} else {
|
||||||
await Promise.all(customJarJson.libraries.map(async library => {
|
name = `${lib[1]}-${lib[2]}${lib[3] ? '-' + lib[3] : ''}.jar`
|
||||||
const lib = library.name.split(':');
|
jarPath = path.join(this.options.root, directory, `${lib[0].replace(/\./g, '/')}/${lib[1]}/${lib[2]}`)
|
||||||
|
}
|
||||||
const jarPath = path.join(this.options.root, 'libraries', `${lib[0].replace(/\./g, '/')}/${lib[1]}/${lib[2]}`);
|
|
||||||
const name = `${lib[1]}-${lib[2]}.jar`;
|
|
||||||
|
|
||||||
if (!fs.existsSync(path.join(jarPath, name))) {
|
if (!fs.existsSync(path.join(jarPath, name))) {
|
||||||
|
// Simple lib support, forgot which addon needed this but here you go, Mr special.
|
||||||
if (library.url) {
|
if (library.url) {
|
||||||
const url = `${library.url}${lib[0].replace(/\./g, '/')}/${lib[1]}/${lib[2]}/${lib[1]}-${lib[2]}.jar`;
|
const url = `${library.url}${lib[0].replace(/\./g, '/')}/${lib[1]}/${lib[2]}/${name}`
|
||||||
await this.downloadAsync(url, jarPath, name, true, 'classes-custom');
|
await this.downloadAsync(url, jarPath, name, true, eventName)
|
||||||
|
} else if (library.downloads && library.downloads.artifact) {
|
||||||
|
await this.downloadAsync(library.downloads.artifact.url, jarPath, name, true, eventName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
counter = counter + 1;
|
|
||||||
|
counter = counter + 1
|
||||||
this.client.emit('progress', {
|
this.client.emit('progress', {
|
||||||
type: 'classes-custom',
|
type: eventName,
|
||||||
task: counter,
|
task: counter,
|
||||||
total: customJarJson.libraries.length
|
total: libraries.length
|
||||||
});
|
|
||||||
libs.push(`${jarPath}${path.sep}${name}`);
|
|
||||||
}));
|
|
||||||
counter = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedClasses = () => {
|
|
||||||
return new Promise(async resolve => {
|
|
||||||
const classes = [];
|
|
||||||
await Promise.all(this.version.libraries.map(async (_lib) => {
|
|
||||||
if(!_lib.downloads.artifact) return;
|
|
||||||
if(this.parseRule(_lib)) return;
|
|
||||||
|
|
||||||
classes.push(_lib);
|
|
||||||
}));
|
|
||||||
resolve(classes);
|
|
||||||
})
|
})
|
||||||
};
|
libs.push(`${jarPath}${path.sep}${name}`)
|
||||||
const parsed = await parsedClasses();
|
}))
|
||||||
|
counter = 0
|
||||||
|
|
||||||
this.client.emit('progress', {
|
return libs
|
||||||
type: 'classes',
|
|
||||||
task: 0,
|
|
||||||
total: parsed.length
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(parsed.map(async (_lib) => {
|
|
||||||
const libraryPath = _lib.downloads.artifact.path;
|
|
||||||
const libraryUrl = _lib.downloads.artifact.url;
|
|
||||||
const libraryHash = _lib.downloads.artifact.sha1;
|
|
||||||
const libraryDirectory = path.join(this.options.root, 'libraries', libraryPath);
|
|
||||||
|
|
||||||
if(!fs.existsSync(libraryDirectory) || !await this.checkSum(libraryHash, libraryDirectory)) {
|
|
||||||
let directory = libraryDirectory.split(path.sep);
|
|
||||||
const name = directory.pop();
|
|
||||||
directory = directory.join(path.sep);
|
|
||||||
|
|
||||||
await this.downloadAsync(libraryUrl, directory, name, true, 'classes');
|
|
||||||
}
|
}
|
||||||
counter = counter + 1;
|
|
||||||
this.client.emit('progress', {
|
|
||||||
type: 'classes',
|
|
||||||
task: counter,
|
|
||||||
total: parsed.length
|
|
||||||
});
|
|
||||||
libs.push(libraryDirectory);
|
|
||||||
}));
|
|
||||||
counter = 0;
|
|
||||||
|
|
||||||
this.client.emit('debug', '[MCLC]: Collected class paths');
|
async getClasses (classJson) {
|
||||||
resolve(libs)
|
let libs = []
|
||||||
});
|
|
||||||
|
if (classJson) {
|
||||||
|
if (classJson.mavenFiles) {
|
||||||
|
await this.downloadToDirectory('libraries', classJson.mavenFiles, 'classes-maven-custom')
|
||||||
|
}
|
||||||
|
libs = (await this.downloadToDirectory('libraries', classJson.libraries, 'classes-custom'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = this.version.libraries.map(lib => {
|
||||||
|
if (lib.downloads.artifact && !this.parseRule(lib)) return lib
|
||||||
|
})
|
||||||
|
|
||||||
|
libs = libs.concat((await this.downloadToDirectory('libraries', parsed, 'classes')))
|
||||||
|
counter = 0
|
||||||
|
|
||||||
|
this.client.emit('debug', '[MCLC]: Collected class paths')
|
||||||
|
return libs
|
||||||
|
}
|
||||||
|
|
||||||
|
popString (path) {
|
||||||
|
const tempArray = path.split('/')
|
||||||
|
tempArray.pop()
|
||||||
|
return tempArray.join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
static cleanUp (array) {
|
static cleanUp (array) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const newArray = [];
|
const newArray = []
|
||||||
|
|
||||||
for(let classPath in array) {
|
for (const classPath in array) {
|
||||||
if(newArray.includes(array[classPath])) continue;
|
if (newArray.includes(array[classPath])) continue
|
||||||
newArray.push(array[classPath]);
|
newArray.push(array[classPath])
|
||||||
}
|
}
|
||||||
resolve(newArray);
|
resolve(newArray)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getLaunchOptions(modification) {
|
async getLaunchOptions (modification) {
|
||||||
return new Promise(async resolve => {
|
const type = modification || this.version
|
||||||
let type = modification || this.version;
|
|
||||||
|
|
||||||
let args = type.minecraftArguments ? type.minecraftArguments.split(' ') : type.arguments.game;
|
let args = type.minecraftArguments
|
||||||
const assetRoot = this.options.overrides.assetRoot || path.join(this.options.root, 'assets');
|
? type.minecraftArguments.split(' ')
|
||||||
const assetPath = this.version.assets === "legacy" || this.version.assets === "pre-1.6" ? path.join(assetRoot, 'legacy') : path.join(assetRoot);
|
: type.arguments.game
|
||||||
|
const assetRoot = this.options.overrides.assetRoot || path.join(this.options.root, 'assets')
|
||||||
|
const assetPath = this.version.assets === 'legacy' ||
|
||||||
|
this.version.assets === 'pre-1.6'
|
||||||
|
? path.join(assetRoot, 'legacy')
|
||||||
|
: path.join(assetRoot)
|
||||||
|
|
||||||
const minArgs = this.options.overrides.minArgs || (this.version.assets === "legacy" || this.version.assets === "pre-1.6") ? 5 : 11;
|
const minArgs = this.options.overrides.minArgs || (this.version.assets === 'legacy' || this.version.assets === 'pre-1.6') ? 5 : 11
|
||||||
if(args.length < minArgs) args = args.concat(this.version.minecraftArguments ? this.version.minecraftArguments.split(' ') : this.version.arguments.game);
|
if (args.length < minArgs) args = args.concat(this.version.minecraftArguments ? this.version.minecraftArguments.split(' ') : this.version.arguments.game)
|
||||||
|
|
||||||
this.options.authorization = await Promise.resolve(this.options.authorization);
|
this.options.authorization = await Promise.resolve(this.options.authorization)
|
||||||
|
|
||||||
const fields = {
|
const fields = {
|
||||||
'${auth_access_token}': this.options.authorization.access_token,
|
'${auth_access_token}': this.options.authorization.access_token,
|
||||||
|
@ -492,66 +494,72 @@ class Handler {
|
||||||
'${assets_root}': assetPath,
|
'${assets_root}': assetPath,
|
||||||
'${game_assets}': assetPath,
|
'${game_assets}': assetPath,
|
||||||
'${version_type}': this.options.version.type
|
'${version_type}': this.options.version.type
|
||||||
};
|
}
|
||||||
|
|
||||||
for (let index = 0; index < args.length; index++) {
|
for (let index = 0; index < args.length; index++) {
|
||||||
if(typeof args[index] === 'object') args.splice(index, 2);
|
if (typeof args[index] === 'object') args.splice(index, 2)
|
||||||
if (Object.keys(fields).includes(args[index])) {
|
if (Object.keys(fields).includes(args[index])) {
|
||||||
args[index] = fields[args[index]];
|
args[index] = fields[args[index]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(this.options.window) this.options.window.fullscreen ? args.push('--fullscreen') : args.push('--width', this.options.window.width, '--height', this.options.window.height);
|
if (this.options.window) {
|
||||||
if(this.options.server) args.push('--server', this.options.server.host, '--port', this.options.server.port || "25565");
|
this.options.window.fullscreen
|
||||||
if(this.options.proxy) args.push(
|
? args.push('--fullscreen')
|
||||||
|
: args.push('--width', this.options.window.width, '--height', this.options.window.height)
|
||||||
|
}
|
||||||
|
if (this.options.server) args.push('--server', this.options.server.host, '--port', this.options.server.port || '25565')
|
||||||
|
if (this.options.proxy) {
|
||||||
|
args.push(
|
||||||
'--proxyHost',
|
'--proxyHost',
|
||||||
this.options.proxy.host,
|
this.options.proxy.host,
|
||||||
'--proxyPort',
|
'--proxyPort',
|
||||||
this.options.proxy.port || "8080",
|
this.options.proxy.port || '8080',
|
||||||
'--proxyUser',
|
'--proxyUser',
|
||||||
this.options.proxy.username,
|
this.options.proxy.username,
|
||||||
'--proxyPass',
|
'--proxyPass',
|
||||||
this.options.proxy.password
|
this.options.proxy.password
|
||||||
);
|
)
|
||||||
if(this.options.customLaunchArgs) this.options.customLaunchArgs.forEach(customArg => args = args.concat(customArg.split(' ')))
|
}
|
||||||
this.client.emit('debug', '[MCLC]: Set launch options');
|
if (this.options.customLaunchArgs) args = args.concat(this.options.customLaunchArgs)
|
||||||
resolve(args);
|
this.client.emit('debug', '[MCLC]: Set launch options')
|
||||||
});
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJVM () {
|
async getJVM () {
|
||||||
const opts = {
|
const opts = {
|
||||||
"windows": "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump",
|
windows: '-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump',
|
||||||
"osx": "-XstartOnFirstThread",
|
osx: '-XstartOnFirstThread',
|
||||||
"linux": "-Xss1M"
|
linux: '-Xss1M'
|
||||||
};
|
}
|
||||||
return opts[this.getOS()]
|
return opts[this.getOS()]
|
||||||
}
|
}
|
||||||
|
|
||||||
getOS () {
|
getOS () {
|
||||||
if (this.options.os) {
|
if (this.options.os) {
|
||||||
return this.options.os;
|
return this.options.os
|
||||||
} else {
|
} else {
|
||||||
switch (process.platform) {
|
switch (process.platform) {
|
||||||
case "win32": return "windows";
|
case 'win32': return 'windows'
|
||||||
case "darwin": return "osx";
|
case 'darwin': return 'osx'
|
||||||
default: return "linux";
|
default: return 'linux'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extractPackage(options = this.options) {
|
async extractPackage (options = this.options) {
|
||||||
return new Promise(async resolve => {
|
return new Promise(async resolve => {
|
||||||
if (options.clientPackage.startsWith('http')) {
|
if (options.clientPackage.startsWith('http')) {
|
||||||
await this.downloadAsync(options.clientPackage, options.root, "clientPackage.zip", true, 'client-package');
|
await this.downloadAsync(options.clientPackage, options.root, 'clientPackage.zip', true, 'client-package')
|
||||||
options.clientPackage = path.join(options.root, "clientPackage.zip")
|
options.clientPackage = path.join(options.root, 'clientPackage.zip')
|
||||||
}
|
}
|
||||||
new zip(options.clientPackage).extractAllTo(options.root, true);
|
new Zip(options.clientPackage).extractAllTo(options.root, true)
|
||||||
this.client.emit('package-extract', true);
|
this.client.emit('package-extract', true)
|
||||||
if(options.removePackage) shelljs.rm(options.clientPackage);
|
if (options.removePackage) shelljs.rm(options.clientPackage)
|
||||||
resolve();
|
|
||||||
});
|
resolve()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Handler;
|
module.exports = Handler
|
||||||
|
|
|
@ -1,87 +1,91 @@
|
||||||
const child = require('child_process');
|
const child = require('child_process')
|
||||||
const path = require('path');
|
const path = require('path')
|
||||||
const handler = require('./handler');
|
const Handler = require('./handler')
|
||||||
const fs = require('fs');
|
const fs = require('fs')
|
||||||
const EventEmitter = require('events').EventEmitter;
|
const EventEmitter = require('events').EventEmitter
|
||||||
|
|
||||||
class MCLCore extends EventEmitter {
|
class MCLCore extends EventEmitter {
|
||||||
constructor() {
|
async launch (options = this.options) {
|
||||||
super();
|
this.options = options
|
||||||
}
|
this.options.root = path.resolve(this.options.root)
|
||||||
|
|
||||||
async launch(options) {
|
|
||||||
this.options = options;
|
|
||||||
this.options.root = path.resolve(this.options.root);
|
|
||||||
this.options.overrides = {
|
this.options.overrides = {
|
||||||
...this.options.overrides,
|
...this.options.overrides,
|
||||||
url: {
|
url: {
|
||||||
meta: "https://launchermeta.mojang.com",
|
meta: 'https://launchermeta.mojang.com',
|
||||||
resource: "https://resources.download.minecraft.net",
|
resource: 'https://resources.download.minecraft.net',
|
||||||
mavenForge: "http://files.minecraftforge.net/maven/",
|
mavenForge: 'http://files.minecraftforge.net/maven/',
|
||||||
defaultRepoForge: "https://libraries.minecraft.net/",
|
defaultRepoForge: 'https://libraries.minecraft.net/',
|
||||||
fallbackMaven: "https://search.maven.org/remotecontent?filepath=",
|
fallbackMaven: 'https://search.maven.org/remotecontent?filepath=',
|
||||||
...this.options.overrides ? this.options.overrides.url : undefined
|
...this.options.overrides
|
||||||
|
? this.options.overrides.url
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ForgeWrapper fork that is maintained on a side repo (https://github.com/Pierce01/ForgeWrapper)
|
||||||
|
this.options.forgeWrapper = {
|
||||||
|
jar: path.join(__dirname, 'fw.jar'),
|
||||||
|
version: '1.4.1-mclc'
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
this.handler = new handler(this);
|
this.handler = new Handler(this)
|
||||||
// Lets the events register. our magic switch!
|
|
||||||
await void(0);
|
|
||||||
|
|
||||||
if (fs.existsSync(path.join(__dirname, '..', 'package.json'))) {
|
if (fs.existsSync(path.join(__dirname, '..', 'package.json'))) {
|
||||||
this.emit('debug', `[MCLC]: MCLC version ${JSON.parse(fs.readFileSync(path.join(__dirname,'..', 'package.json'), { encoding: 'utf8' })).version}`);
|
this.emit('debug', `[MCLC]: MCLC version ${JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), { encoding: 'utf8' })).version}`)
|
||||||
} else { this.emit('debug', `[MCLC]: Package JSON not found, skipping MCLC version check.`); }
|
} else { this.emit('debug', '[MCLC]: Package JSON not found, skipping MCLC version check.') }
|
||||||
const java = await this.handler.checkJava(this.options.javaPath || 'java');
|
const java = await this.handler.checkJava(this.options.javaPath || 'java')
|
||||||
if (!java.run) {
|
if (!java.run) {
|
||||||
this.emit('debug', `[MCLC]: Couldn't start Minecraft due to: ${java.message}`);
|
this.emit('debug', `[MCLC]: Couldn't start Minecraft due to: ${java.message}`)
|
||||||
this.emit('close', 1);
|
this.emit('close', 1)
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(this.options.root)) {
|
if (!fs.existsSync(this.options.root)) {
|
||||||
this.emit('debug', '[MCLC]: Attempting to create root folder');
|
this.emit('debug', '[MCLC]: Attempting to create root folder')
|
||||||
fs.mkdirSync(this.options.root);
|
fs.mkdirSync(this.options.root)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.clientPackage) {
|
if (this.options.clientPackage) {
|
||||||
this.emit('debug', `[MCLC]: Extracting client package to ${this.options.root}`);
|
this.emit('debug', `[MCLC]: Extracting client package to ${this.options.root}`)
|
||||||
await this.handler.extractPackage();
|
await this.handler.extractPackage()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.installer) {
|
if (this.options.installer) {
|
||||||
// So the forge installer can run without breaking :)
|
// So the forge installer can run without breaking :)
|
||||||
const profilePath = path.join(this.options.root, 'launcher_profiles.json');
|
const profilePath = path.join(this.options.root, 'launcher_profiles.json')
|
||||||
if(!fs.existsSync(profilePath))
|
if (!fs.existsSync(profilePath)) { fs.writeFileSync(profilePath, JSON.stringify({}, null, 4)) }
|
||||||
fs.writeFileSync(profilePath, JSON.stringify({}, null, 4));
|
|
||||||
await this.handler.runInstaller(this.options.installer)
|
await this.handler.runInstaller(this.options.installer)
|
||||||
}
|
}
|
||||||
|
|
||||||
const directory = this.options.overrides.directory || path.join(this.options.root, 'versions', this.options.version.number);
|
const directory = this.options.overrides.directory || path.join(this.options.root, 'versions', this.options.version.number)
|
||||||
this.options.directory = directory;
|
this.options.directory = directory
|
||||||
|
|
||||||
// Version JSON for the main launcher folder
|
// Version JSON for the main launcher folder
|
||||||
const versionFile = await this.handler.getVersion();
|
const versionFile = await this.handler.getVersion()
|
||||||
const mcPath = this.options.overrides.minecraftJar || (this.options.version.custom ? path.join(this.options.root, 'versions', this.options.version.custom , `${this.options.version.custom}.jar`):
|
const mcPath = this.options.overrides.minecraftJar || (this.options.version.custom
|
||||||
path.join(directory, `${this.options.version.number}.jar`));
|
? path.join(this.options.root, 'versions', this.options.version.custom, `${this.options.version.custom}.jar`)
|
||||||
const nativePath = await this.handler.getNatives();
|
: path.join(directory, `${this.options.version.number}.jar`))
|
||||||
|
const nativePath = await this.handler.getNatives()
|
||||||
|
|
||||||
if (!fs.existsSync(mcPath)) {
|
if (!fs.existsSync(mcPath)) {
|
||||||
this.emit('debug', '[MCLC]: Attempting to download Minecraft version jar');
|
this.emit('debug', '[MCLC]: Attempting to download Minecraft version jar')
|
||||||
await this.handler.getJar();
|
await this.handler.getJar()
|
||||||
}
|
}
|
||||||
|
|
||||||
let forge = null;
|
let forge = null
|
||||||
let custom = null;
|
let custom = null
|
||||||
if (this.options.forge) {
|
if (this.options.forge) {
|
||||||
this.emit('debug', '[MCLC]: Detected Forge in options, getting dependencies');
|
this.options.forge = path.resolve(this.options.forge)
|
||||||
forge = await this.handler.getForgeDependenciesLegacy();
|
|
||||||
|
this.emit('debug', '[MCLC]: Detected Forge in options, getting dependencies')
|
||||||
|
forge = await this.handler.getForgeDependenciesLegacy()
|
||||||
|
if (forge === false) custom = await this.handler.getForgedWrapped()
|
||||||
}
|
}
|
||||||
if(this.options.version.custom) {
|
if (this.options.version.custom || custom) {
|
||||||
this.emit('debug', '[MCLC]: Detected custom in options, setting custom version file');
|
this.emit('debug', '[MCLC]: Detected custom in options, setting custom version file')
|
||||||
custom = JSON.parse(fs.readFileSync(path.join(this.options.root, 'versions', this.options.version.custom, `${this.options.version.custom}.json`), { encoding: 'utf8' }));
|
custom = custom || JSON.parse(fs.readFileSync(path.join(this.options.root, 'versions', this.options.version.custom, `${this.options.version.custom}.json`), { encoding: 'utf8' }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = [];
|
const args = []
|
||||||
|
|
||||||
// Jvm
|
// Jvm
|
||||||
let jvm = [
|
let jvm = [
|
||||||
|
@ -92,49 +96,51 @@ class MCLCore extends EventEmitter {
|
||||||
`-Djava.library.path=${nativePath}`,
|
`-Djava.library.path=${nativePath}`,
|
||||||
`-Xmx${this.options.memory.max}M`,
|
`-Xmx${this.options.memory.max}M`,
|
||||||
`-Xms${this.options.memory.min}M`
|
`-Xms${this.options.memory.min}M`
|
||||||
];
|
]
|
||||||
if (this.handler.getOS() === 'osx') {
|
if (this.handler.getOS() === 'osx') {
|
||||||
if(parseInt(versionFile.id.split('.')[1]) > 12) jvm.push(await this.handler.getJVM());
|
if (parseInt(versionFile.id.split('.')[1]) > 12) jvm.push(await this.handler.getJVM())
|
||||||
} else jvm.push(await this.handler.getJVM());
|
} else jvm.push(await this.handler.getJVM())
|
||||||
|
|
||||||
if(this.options.customArgs) jvm = jvm.concat(this.options.customArgs);
|
if (this.options.customArgs) jvm = jvm.concat(this.options.customArgs)
|
||||||
|
|
||||||
const classes = this.options.overrides.classes || await handler.cleanUp(await this.handler.getClasses());
|
const classes = this.options.overrides.classes || await Handler.cleanUp(await this.handler.getClasses(custom))
|
||||||
let classPaths = ['-cp'];
|
const classPaths = ['-cp']
|
||||||
const separator = this.handler.getOS() === "windows" ? ";" : ":";
|
const separator = this.handler.getOS() === 'windows' ? ';' : ':'
|
||||||
this.emit('debug', `[MCLC]: Using ${separator} to separate class paths`);
|
this.emit('debug', `[MCLC]: Using ${separator} to separate class paths`)
|
||||||
if (forge) {
|
if (forge) {
|
||||||
this.emit('debug', '[MCLC]: Setting Forge class paths');
|
this.emit('debug', '[MCLC]: Setting Forge class paths')
|
||||||
classPaths.push(`${path.resolve(this.options.forge)}${separator}${forge.paths.join(separator)}${separator}${classes.join(separator)}${separator}${mcPath}`);
|
classPaths.push(`${path.resolve(this.options.forge)}${separator}${forge.paths.join(separator)}${separator}${classes.join(separator)}${separator}${mcPath}`)
|
||||||
classPaths.push(forge.forge.mainClass)
|
classPaths.push(forge.forge.mainClass)
|
||||||
} else {
|
} else {
|
||||||
const file = custom || versionFile;
|
const file = custom || versionFile
|
||||||
// So mods like fabric work.
|
// So mods like fabric work.
|
||||||
const jar = fs.existsSync(mcPath) ? `${separator}${mcPath}` : `${separator}${path.join(directory, `${this.options.version.number}.jar`)}`;
|
const jar = fs.existsSync(mcPath)
|
||||||
classPaths.push(`${classes.join(separator)}${jar}`);
|
? `${separator}${mcPath}`
|
||||||
classPaths.push(file.mainClass);
|
: `${separator}${path.join(directory, `${this.options.version.number}.jar`)}`
|
||||||
|
classPaths.push(`${classes.join(separator)}${jar}`)
|
||||||
|
classPaths.push(file.mainClass)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download version's assets
|
// Download version's assets
|
||||||
this.emit('debug', '[MCLC]: Attempting to download assets');
|
this.emit('debug', '[MCLC]: Attempting to download assets')
|
||||||
await this.handler.getAssets();
|
await this.handler.getAssets()
|
||||||
|
|
||||||
// Launch options. Thank you Lyrus for the reformat <3
|
// Launch options. Thank you Lyrus for the reformat <3
|
||||||
const modification = forge ? forge.forge : null || custom ? custom : null;
|
const modification = forge ? forge.forge : null || custom ? custom : null
|
||||||
const launchOptions = await this.handler.getLaunchOptions(modification);
|
const launchOptions = await this.handler.getLaunchOptions(modification)
|
||||||
|
|
||||||
const launchArguments = args.concat(jvm, classPaths, launchOptions);
|
const launchArguments = args.concat(jvm, classPaths, launchOptions)
|
||||||
this.emit('arguments', launchArguments);
|
this.emit('arguments', launchArguments)
|
||||||
this.emit('debug', `[MCLC]: Launching with arguments ${launchArguments.join(' ')}`);
|
this.emit('debug', `[MCLC]: Launching with arguments ${launchArguments.join(' ')}`)
|
||||||
|
|
||||||
const minecraft = child.spawn(this.options.javaPath ? this.options.javaPath : 'java', launchArguments,
|
const minecraft = child.spawn(this.options.javaPath ? this.options.javaPath : 'java', launchArguments,
|
||||||
{cwd: this.options.overrides.cwd || this.options.root});
|
{ cwd: this.options.overrides.cwd || this.options.root })
|
||||||
minecraft.stdout.on('data', (data) => this.emit('data', data.toString('utf-8')));
|
minecraft.stdout.on('data', (data) => this.emit('data', data.toString('utf-8')))
|
||||||
minecraft.stderr.on('data', (data) => this.emit('data', data.toString('utf-8')));
|
minecraft.stderr.on('data', (data) => this.emit('data', data.toString('utf-8')))
|
||||||
minecraft.on('close', (code) => this.emit('close', code));
|
minecraft.on('close', (code) => this.emit('close', code))
|
||||||
|
|
||||||
return minecraft;
|
return minecraft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = MCLCore;
|
module.exports = MCLCore
|
||||||
|
|
4
index.js
4
index.js
|
@ -1,4 +1,4 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Client: require('./components/launcher'),
|
Client: require('./components/launcher'),
|
||||||
Authenticator: require('./components/authenticator'),
|
Authenticator: require('./components/authenticator')
|
||||||
};
|
}
|
||||||
|
|
1647
package-lock.json
generated
1647
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "minecraft-launcher-core",
|
"name": "minecraft-launcher-core",
|
||||||
"version": "3.13.3",
|
"version": "3.14.0",
|
||||||
"description": "Lightweight module that downloads and runs Minecraft using javascript / NodeJS",
|
"description": "Lightweight module that downloads and runs Minecraft using javascript / NodeJS",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -10,9 +10,16 @@
|
||||||
"shelljs": "^0.8.2",
|
"shelljs": "^0.8.2",
|
||||||
"uuid": "^3.3.2"
|
"uuid": "^3.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {},
|
"devDependencies": {
|
||||||
|
"eslint": "^6.8.0",
|
||||||
|
"eslint-config-standard": "^14.1.1",
|
||||||
|
"eslint-plugin-import": "^2.20.2",
|
||||||
|
"eslint-plugin-node": "^11.1.0",
|
||||||
|
"eslint-plugin-promise": "^4.2.1",
|
||||||
|
"eslint-plugin-standard": "^4.0.1"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "eslint ."
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
Loading…
Add table
Reference in a new issue