Files
GDL-Continuation/src/common/reducers/actions.js
T
2025-07-31 01:32:02 +02:00

3485 lines
99 KiB
JavaScript

import axios from 'axios';
import path from 'path';
import { ipcRenderer } from 'electron';
import { v5 as uuid } from 'uuid';
import { machineId } from 'node-machine-id';
import fse, { remove } from 'fs-extra';
import coerce from 'semver/functions/coerce';
import gte from 'semver/functions/gte';
import lt from 'semver/functions/lt';
import lte from 'semver/functions/lte';
import gt from 'semver/functions/gt';
import log from 'electron-log';
import omitBy from 'lodash/omitBy';
import { pipeline } from 'stream';
import he from 'he';
import zlib from 'zlib';
import lockfile from 'lockfile';
import omit from 'lodash/omit';
import Seven from 'node-7z';
import { push } from 'connected-react-router';
import { spawn } from 'child_process';
import symlink from 'symlink-dir';
import fss, { promises as fs } from 'fs';
import originalFs from 'original-fs';
import pMap from 'p-map';
import makeDir from 'make-dir';
import { major, minor, patch, prerelease } from 'semver';
import { generate as generateRandomString } from 'randomstring';
import { XMLParser } from 'fast-xml-parser';
import * as ActionTypes from './actionTypes';
import {
ACCOUNT_MICROSOFT,
ACCOUNT_MOJANG,
CURSEFORGE,
FABRIC,
FMLLIBS_FORGE_BASE_URL,
FMLLIBS_OUR_BASE_URL,
FORGE,
GDL_LEGACYJAVAFIXER_MOD_URL,
LATEST_JAVA_VERSION,
MC_RESOURCES_URL,
MC_STARTUP_METHODS,
MICROSOFT_OAUTH_CLIENT_ID,
MICROSOFT_OAUTH_REDIRECT_URL,
NEWS_URL
} from '../utils/constants';
import {
getAddon,
getAddonCategories,
getAddonFile,
getAddonFiles,
getAddonsByFingerprint,
getCFVersionIds,
getFabricJson,
getFabricManifest,
getForgeManifest,
getJavaLatestManifest,
getJavaManifest,
getMcManifest,
getMultipleAddons,
mcAuthenticate,
mcInvalidate,
mcRefresh,
mcValidate,
msAuthenticateMinecraft,
msAuthenticateXBL,
msAuthenticateXSTS,
msExchangeCodeForAccessToken,
msMinecraftProfile,
msOAuthRefresh
} from '../api';
import {
_getAccounts,
_getAssetsPath,
_getCurrentAccount,
_getCurrentDownloadItem,
_getDataStorePath,
_getInstance,
_getInstancesPath,
_getJavaPath,
_getLibrariesPath,
_getMinecraftVersionsPath,
_getTempPath
} from '../utils/selectors';
import {
convertCompletePathToInstance,
convertcurseForgeToCanonical,
copyAssetsToLegacy,
copyAssetsToResources,
downloadAddonZip,
extractAll,
extractFabricVersionFromManifest,
extractNatives,
filterFabricFilesByVersion,
filterForgeFilesByVersion,
get7zPath,
getFileHash,
getFilesRecursive,
getJVMArguments112,
getJVMArguments113,
getPatchedInstanceType,
getPlayerSkin,
isInstanceFolderPath,
isMod,
librariesMapper,
mavenToArray,
normalizeModData,
patchForge113,
reflect
} from '../../app/desktop/utils';
import ga from '../utils/analytics';
import {
downloadFile,
downloadInstanceFiles
} from '../../app/desktop/utils/downloader';
import {
addQuotes,
getFileMurmurHash2,
getSize,
makeInstanceRestorePoint,
removeDuplicates,
replaceLibraryDirectory
} from '../utils';
import { UPDATE_CONCURRENT_DOWNLOADS } from './settings/actionTypes';
import { UPDATE_MODAL } from './modals/actionTypes';
import PromiseQueue from '../../app/desktop/utils/PromiseQueue';
import fmlLibsMapping from '../../app/desktop/utils/fmllibs';
import { openModal, closeModal } from './modals/actions';
import forgePatcher from '../utils/forgePatcher';
import browserDownload from '../utils/browserDownload';
export function initManifests() {
return async (dispatch, getState) => {
const { app } = getState();
let mc = null;
try {
mc = (await getMcManifest()).data;
dispatch({
type: ActionTypes.UPDATE_VANILLA_MANIFEST,
data: mc
});
} catch (err) {
console.error(err);
}
const getFabricVersions = async () => {
const fabric = (await getFabricManifest()).data;
dispatch({
type: ActionTypes.UPDATE_FABRIC_MANIFEST,
data: fabric
});
return fabric;
};
const getJavaManifestVersions = async () => {
const java = (await getJavaManifest()).data;
dispatch({
type: ActionTypes.UPDATE_JAVA_MANIFEST,
data: java
});
return java;
};
const getJavaLatestManifestVersions = async () => {
const java = (await getJavaLatestManifest()).data;
dispatch({
type: ActionTypes.UPDATE_JAVA_LATEST_MANIFEST,
data: java
});
return java;
};
const getAddonCategoriesVersions = async () => {
const curseforgeCategories = await getAddonCategories();
dispatch({
type: ActionTypes.UPDATE_CURSEFORGE_CATEGORIES_MANIFEST,
data: curseforgeCategories
});
return curseforgeCategories;
};
const getCurseForgeVersionIds = async () => {
const versionIds = await getCFVersionIds();
const hm = {};
for (const v of versionIds) {
for (const version of v.versions) {
hm[version] = v.type;
}
}
dispatch({
type: ActionTypes.UPDATE_CURSEFORGE_VERSION_IDS,
data: hm
});
return hm;
};
const getForgeVersions = async () => {
const forge = (await getForgeManifest()).data;
const forgeVersions = {};
// Looping over vanilla versions, create a new entry in forge object
// and add to it all correct versions
mc.versions.forEach(v => {
if (forge[v.id]) {
// Monkeypatch manifest since forge changed the format
if (v.id.includes('-'))
forge[v.id].map(forgeV => forgePatcher(forgeV, v.id));
else forgeVersions[v.id] = forge[v.id];
}
});
dispatch({
type: ActionTypes.UPDATE_FORGE_MANIFEST,
data: omitBy(forgeVersions, v => v.length === 0)
});
return omitBy(forgeVersions, v => v.length === 0);
};
// Using reflect to avoid rejection
const [fabric, java, javaLatest, categories, forge, CFVersionIds] =
await Promise.all([
reflect(getFabricVersions()),
reflect(getJavaManifestVersions()),
reflect(getJavaLatestManifestVersions()),
reflect(getAddonCategoriesVersions()),
reflect(getForgeVersions()),
reflect(getCurseForgeVersionIds())
]);
if (fabric.e || java.e || categories.e || forge.e || CFVersionIds.e) {
console.error(fabric, java, categories, forge);
}
return {
mc: mc || app.vanillaManifest,
fabric: fabric.status ? fabric.v : app.fabricManifest,
java: java.status ? java.v : app.javaManifest,
javaLatest: javaLatest.status ? javaLatest.v : app.javaLatestManifest,
categories: categories.status ? categories.v : app.curseforgeCategories,
forge: forge.status ? forge.v : app.forgeManifest,
curseforgeVersionIds: CFVersionIds.status
? CFVersionIds.v
: app.curseforgeVersionIds
};
};
}
export function initNews() {
return async (dispatch, getState) => {
const {
news,
loading: { minecraftNews }
} = getState();
if (news.length === 0 && !minecraftNews.isRequesting) {
try {
const { data: newsXml } = await axios.get(NEWS_URL);
const parser = new XMLParser();
const newsArr =
parser.parse(newsXml)?.rss?.channel?.item?.map(newsEntry => ({
title: newsEntry.title,
description: newsEntry.description,
image: `https://www.minecraft.net${newsEntry.imageURL}`,
url: newsEntry.link,
guid: newsEntry.guid
})) || [];
dispatch({
type: ActionTypes.UPDATE_NEWS,
news: newsArr
.map(v => ({
...v,
title: he.decode(v?.title),
description: he.decode(v?.description)
}))
.splice(0, 10)
});
} catch (err) {
console.error(err.message);
}
}
};
}
export function updateAccount(uuidVal, account) {
return dispatch => {
dispatch({
type: ActionTypes.UPDATE_ACCOUNT,
id: uuidVal,
account
});
};
}
export function switchToFirstValidAccount(id) {
return async (dispatch, getState) => {
const state = getState();
const accounts = _getAccounts(state);
const currentAccountId = id || state.app.currentAccountId;
let found = null;
for (let i = 0; i < accounts.length; i += 1) {
if (found || accounts[i].selectedProfile.id === currentAccountId)
continue; //eslint-disable-line
try {
dispatch(updateCurrentAccountId(accounts[i].selectedProfile.id));
// eslint-disable-next-line no-await-in-loop
await dispatch(
accounts[i].accountType === ACCOUNT_MICROSOFT
? loginWithOAuthAccessToken()
: loginWithAccessToken()
);
found = accounts[i].selectedProfile.id;
} catch {
dispatch(
updateAccount(accounts[i].selectedProfile.id, {
...accounts[i],
accessToken: accounts[i].accessToken
})
);
console.error(`Failed to validate ${accounts[i].selectedProfile.id}`);
}
}
if (!found) {
dispatch(updateCurrentAccountId(null));
}
return found;
};
}
export function removeAccount(id) {
return async (dispatch, getState) => {
const state = getState();
const { currentAccountId } = state.app;
let newId = id;
if (currentAccountId === id) {
newId = await dispatch(switchToFirstValidAccount(id));
}
dispatch({
type: ActionTypes.REMOVE_ACCOUNT,
id
});
return newId;
};
}
export function updateIsNewUser(isNewUser) {
return async dispatch => {
dispatch({
type: ActionTypes.UPDATE_IS_NEW_USER,
isNewUser
});
};
}
export function updateCurrentAccountId(id) {
return async dispatch => {
dispatch({
type: ActionTypes.UPDATE_CURRENT_ACCOUNT_ID,
id
});
};
}
export function updateConcurrentDownloads(concurrentDownloads) {
return async dispatch => {
dispatch({
type: UPDATE_CONCURRENT_DOWNLOADS,
concurrentDownloads
});
};
}
export function updateUpdateAvailable(updateAvailable) {
return async dispatch => {
dispatch({
type: ActionTypes.UPDATE_UPDATE_AVAILABLE,
updateAvailable
});
};
}
export function updateDownloadProgress(percentage) {
return (dispatch, getState) => {
const { currentDownload } = getState();
dispatch({
type: ActionTypes.UPDATE_DOWNLOAD_PROGRESS,
instanceName: currentDownload,
percentage: Number(percentage).toFixed(0)
});
ipcRenderer.invoke('update-progress-bar', percentage);
};
}
export function updateUserData(userData) {
return dispatch => {
dispatch({
type: ActionTypes.UPDATE_USERDATA,
path: userData
});
return userData;
};
}
export function updateMessage(message) {
return dispatch => {
dispatch({
type: ActionTypes.UPDATE_MESSAGE,
message
});
};
}
export function downloadJavaLegacyFixer() {
return async (dispatch, getState) => {
const state = getState();
await downloadFile(
path.join(_getDataStorePath(state), '__JLF__.jar'),
GDL_LEGACYJAVAFIXER_MOD_URL
);
};
}
export function login(username, password, redirect = true) {
return async (dispatch, getState) => {
const {
app: { isNewUser, clientToken }
} = getState();
if (!username || !password) {
throw new Error('No username or password provided');
}
try {
let data = null;
try {
({ data } = await mcAuthenticate(username, password, clientToken));
data.accountType = ACCOUNT_MOJANG;
} catch (err) {
console.error(err);
throw new Error('Invalid username or password.');
}
if (!data?.selectedProfile?.id) {
throw new Error("It looks like you didn't buy the game.");
}
const skinUrl = await getPlayerSkin(data.selectedProfile.id);
if (skinUrl) {
data.skin = skinUrl;
}
dispatch(updateAccount(data.selectedProfile.id, data));
dispatch(updateCurrentAccountId(data.selectedProfile.id));
if (!isNewUser) {
if (redirect) {
dispatch(push('/home'));
}
} else {
dispatch(updateIsNewUser(false));
if (redirect) {
dispatch(push('/onboarding'));
}
}
} catch (err) {
console.error(err);
throw new Error(err);
}
};
}
export function loginWithOAuthAccessToken(redirect = true) {
return async (dispatch, getState) => {
const state = getState();
const currentAccount = _getCurrentAccount(state);
const {
accessToken,
selectedProfile,
msOAuth: { msRefreshToken, msExpiresAt },
user: { username: mcUserName }
} = currentAccount;
if (!accessToken) throw new Error();
// Check if token already expired
if (Date.now() >= msExpiresAt) {
// Token expired
try {
const clientId = MICROSOFT_OAUTH_CLIENT_ID;
let msRefreshedAccessToken = null;
let msRefreshedRefreshToken = null;
let msRefreshedExpiresIn = null;
let msRefreshedExpiresAt = null;
try {
({
data: {
access_token: msRefreshedAccessToken,
refresh_token: msRefreshedRefreshToken,
expires_in: msRefreshedExpiresIn
}
} = await msOAuthRefresh(clientId, msRefreshToken));
msRefreshedExpiresAt = Date.now() + 1000 * msRefreshedExpiresIn;
} catch (error) {
console.error(error);
throw new Error('Error occurred while refreshing Microsoft token.');
}
let xblToken = null;
let userHash = null;
try {
({
data: {
Token: xblToken,
DisplayClaims: {
xui: [{ uhs: userHash }]
}
}
} = await msAuthenticateXBL(msRefreshedAccessToken));
} catch (error) {
console.error(error);
throw new Error('Error occurred while logging in Xbox Live .');
}
let xstsToken = null;
try {
({
data: { Token: xstsToken }
} = await msAuthenticateXSTS(xblToken));
} catch (error) {
console.error(error);
throw new Error(
'Error occurred while fetching token from Xbox Secure Token Service.'
);
}
let mcRefreshedAccessToken = null;
let mcRefreshedExpiresIn = null;
let mcRefreshedExpiresAt = null;
try {
({
data: {
access_token: mcRefreshedAccessToken,
expires_in: mcRefreshedExpiresIn
}
} = await msAuthenticateMinecraft(userHash, xstsToken));
mcRefreshedExpiresAt = Date.now() + 1000 * mcRefreshedExpiresIn;
} catch (error) {
console.error(error);
throw new Error('Error occurred while logging in Minecraft.');
}
const skinUrl = await getPlayerSkin(selectedProfile.id);
const account = {
accountType: ACCOUNT_MICROSOFT,
accessToken: mcRefreshedAccessToken,
msOAuth: {
msAccessToken: msRefreshedAccessToken,
msRefreshToken: msRefreshedRefreshToken || msRefreshToken,
msExpiresAt: msRefreshedExpiresAt,
mcExpiresAt: mcRefreshedExpiresAt,
xblToken,
xstsToken,
userHash
},
selectedProfile: {
id: selectedProfile.id,
name: selectedProfile.name
},
skin: skinUrl || undefined,
user: {
username: mcUserName
}
};
dispatch(updateAccount(selectedProfile.id, account));
dispatch(updateCurrentAccountId(selectedProfile.id));
if (redirect) {
dispatch(push('/home'));
}
} catch (error) {
console.error(error);
throw new Error(error);
}
} else {
// Only reload skin
try {
const skinUrl = await getPlayerSkin(selectedProfile.id);
if (skinUrl) {
dispatch(
updateAccount(selectedProfile.id, {
...currentAccount,
skin: skinUrl
})
);
}
} catch (err) {
console.warn('Could not fetch skin');
} finally {
if (redirect) {
dispatch(push('/home'));
}
}
}
};
}
export function loginWithAccessToken(redirect = true) {
return async (dispatch, getState) => {
const state = getState();
const currentAccount = _getCurrentAccount(state);
const { accessToken, clientToken, selectedProfile } = currentAccount;
if (!accessToken) throw new Error();
try {
await mcValidate(accessToken, clientToken);
try {
const skinUrl = await getPlayerSkin(selectedProfile.id);
if (skinUrl) {
dispatch(
updateAccount(selectedProfile.id, {
...currentAccount,
skin: skinUrl
})
);
}
} catch (err) {
console.warn('Could not fetch skin');
}
dispatch(push('/home'));
} catch (error) {
console.error(error);
// Trying refreshing the stored access token
if (error.response && error.response.status === 403) {
try {
const { data } = await mcRefresh(accessToken, clientToken);
const skinUrl = await getPlayerSkin(data.selectedProfile.id);
if (skinUrl) {
data.skin = skinUrl;
}
dispatch(updateAccount(data.selectedProfile.id, data));
dispatch(updateCurrentAccountId(data.selectedProfile.id));
if (redirect) {
dispatch(push('/home'));
}
} catch (nestedError) {
console.error(error, nestedError);
if (redirect) {
dispatch(push('/'));
}
throw new Error();
}
} else if (error.message === 'Network Error') {
if (redirect) {
dispatch(push('/home'));
}
}
}
};
}
export function loginThroughNativeLauncher() {
return async (dispatch, getState) => {
const {
app: { isNewUser }
} = getState();
const homedir = await ipcRenderer.invoke('getAppdataPath');
const mcFolder = process.platform === 'darwin' ? 'minecraft' : '.minecraft';
const vanillaMCPath =
process.platform === 'linux'
? path.resolve(homedir, '../', mcFolder)
: path.join(homedir, mcFolder);
const vnlJson = await fse.readJson(
path.join(vanillaMCPath, 'launcher_profiles.json')
);
try {
const { clientToken } = vnlJson;
const { account } = vnlJson.selectedUser;
const { accessToken } = vnlJson.authenticationDatabase[account];
const { data } = await mcRefresh(accessToken, clientToken);
data.accountType = ACCOUNT_MOJANG;
const skinUrl = await getPlayerSkin(data.selectedProfile.id);
if (skinUrl) {
data.skin = skinUrl;
}
// We need to update the accessToken in launcher_profiles.json
vnlJson.authenticationDatabase[account].accessToken = data.accessToken;
await fse.outputJson(
path.join(vanillaMCPath, 'launcher_profiles.json'),
vnlJson
);
dispatch(updateAccount(data.selectedProfile.id, data));
dispatch(updateCurrentAccountId(data.selectedProfile.id));
if (isNewUser) {
dispatch(updateIsNewUser(false));
dispatch(push('/onboarding'));
} else {
dispatch(push('/home'));
}
} catch (err) {
throw new Error(err);
}
};
}
export function loginOAuth(redirect = true) {
return async (dispatch, getState) => {
const {
app: { isNewUser /* , clientToken */ }
} = getState();
try {
const clientId = MICROSOFT_OAUTH_CLIENT_ID;
const codeVerifier = generateRandomString(128);
const redirectUrl = MICROSOFT_OAUTH_REDIRECT_URL;
let authCode = null;
try {
authCode = await ipcRenderer.invoke(
'msLoginOAuth',
clientId,
codeVerifier,
redirectUrl
);
} catch (error) {
console.error(error);
throw new Error('Error occurred while logging in Microsoft.');
}
let msAccessToken = null;
let msRefreshToken = null;
let msExpiresIn = null;
let msExpiresAt = null;
try {
({
data: {
access_token: msAccessToken,
refresh_token: msRefreshToken,
expires_in: msExpiresIn
}
} = await msExchangeCodeForAccessToken(
clientId,
redirectUrl,
authCode,
codeVerifier
));
msExpiresAt = Date.now() + 1000 * msExpiresIn;
} catch (error) {
console.error(error);
throw new Error('Error occurred while making logging in Microsoft .');
}
let xblToken = null;
let userHash = null;
try {
({
data: {
Token: xblToken,
DisplayClaims: {
xui: [{ uhs: userHash }]
}
}
} = await msAuthenticateXBL(msAccessToken));
} catch (error) {
console.error(error);
throw new Error('Error occurred while logging in Xbox Live .');
}
let xstsToken = null;
try {
({
data: { Token: xstsToken }
} = await msAuthenticateXSTS(xblToken));
} catch (error) {
console.error(error);
throw new Error(
'Error occurred while fetching token from Xbox Secure Token Service.'
);
}
let mcAccessToken = null;
let mcExpiresIn = null;
let mcExpiresAt = null;
try {
({
data: { access_token: mcAccessToken, expires_in: mcExpiresIn }
} = await msAuthenticateMinecraft(userHash, xstsToken));
mcExpiresAt = Date.now() + 1000 * mcExpiresIn;
} catch (error) {
console.error(error);
throw new Error('Error occurred while logging in Minecraft.');
}
let mcUserId = null;
let mcUserName = null;
try {
({
data: { id: mcUserId, name: mcUserName }
} = await msMinecraftProfile(mcAccessToken));
} catch (error) {
console.error(error);
if (error?.response?.status === 404) {
throw new Error("It looks like you didn't buy the game.");
}
throw new Error('Error occurred while fetching Minecraft profile.');
}
const skinUrl = await getPlayerSkin(mcUserId);
const account = {
accountType: ACCOUNT_MICROSOFT,
accessToken: mcAccessToken,
msOAuth: {
msAccessToken,
msRefreshToken,
msExpiresAt,
mcExpiresAt,
xblToken,
xstsToken,
userHash
},
selectedProfile: {
id: mcUserId,
name: mcUserName
},
skin: skinUrl || undefined,
user: {
username: mcUserName
}
};
dispatch(updateAccount(mcUserId, account));
dispatch(updateCurrentAccountId(mcUserId));
if (!isNewUser) {
if (redirect) {
dispatch(push('/home'));
}
} else {
dispatch(updateIsNewUser(false));
if (redirect) {
dispatch(push('/onboarding'));
}
}
} catch (error) {
console.error(error);
throw new Error(error);
}
};
}
export function logout() {
return (dispatch, getState) => {
const state = getState();
const {
clientToken,
accessToken,
selectedProfile: { id }
} = _getCurrentAccount(state);
mcInvalidate(accessToken, clientToken).catch(console.error);
dispatch(removeAccount(id));
dispatch(push('/'));
};
}
export function checkClientToken() {
return async (dispatch, getState) => {
const {
app: { clientToken }
} = getState();
if (clientToken) return clientToken;
const MY_NAMESPACE = '1dfd2800-790c-11ea-a17c-e930c253ce6b';
const machineUuid = await machineId();
const newToken = uuid(machineUuid, MY_NAMESPACE);
dispatch({
type: ActionTypes.UPDATE_CLIENT_TOKEN,
clientToken: newToken
});
return newToken;
};
}
export function updateLatestModManifests(manifests) {
return dispatch => {
dispatch({
type: ActionTypes.UPDATE_MOD_MANIFESTS,
manifests
});
};
}
export function clearLatestModManifests() {
return dispatch => {
dispatch({
type: ActionTypes.CLEAR_MOD_MANIFESTS
});
};
}
export function updateCurrentDownload(instanceName) {
return dispatch => {
dispatch({
type: ActionTypes.UPDATE_CURRENT_DOWNLOAD,
instanceName
});
};
}
export function updateSelectedInstance(name) {
return dispatch => {
dispatch({
type: ActionTypes.UPDATE_SELECTED_INSTANCE,
name
});
};
}
export function addStartedInstance(instance) {
return dispatch => {
dispatch({
type: ActionTypes.ADD_STARTED_INSTANCE,
instance
});
};
}
export function updateStartedInstance(instance) {
return dispatch => {
dispatch({
type: ActionTypes.UPDATE_STARTED_INSTANCE,
instance
});
};
}
export function removeStartedInstance(instanceName) {
return dispatch => {
dispatch({
type: ActionTypes.REMOVE_STARTED_INSTANCE,
instanceName
});
};
}
export function removeDownloadFromQueue(instanceName) {
return async (dispatch, getState) => {
const lockFilePath = path.join(
_getInstancesPath(getState()),
instanceName,
'installing.lock'
);
const isLocked = await new Promise((resolve, reject) => {
lockfile.check(lockFilePath, (err, locked) => {
if (err) reject(err);
resolve(locked);
});
});
if (isLocked) {
lockfile.unlock(lockFilePath, err => {
if (err) console.log(err);
});
}
dispatch({
type: ActionTypes.UPDATE_CURRENT_DOWNLOAD,
instanceName: null
});
dispatch({
type: ActionTypes.REMOVE_DOWNLOAD_FROM_QUEUE,
instanceName
});
};
}
export function updateDownloadStatus(instanceName, status) {
return dispatch => {
dispatch({
type: ActionTypes.UPDATE_DOWNLOAD_STATUS,
status,
instanceName
});
dispatch(updateDownloadProgress(0));
};
}
export function updateLastUpdateVersion(version) {
return dispatch => {
dispatch({
type: ActionTypes.UPDATE_LAST_UPDATE_VERSION,
version
});
};
}
export function updateDownloadCurrentPhase(instanceName, status) {
return dispatch => {
dispatch({
type: ActionTypes.UPDATE_DOWNLOAD_STATUS,
status,
instanceName
});
};
}
export function updateInstanceConfig(
instanceName,
updateFunction,
forceWrite = false
) {
return async (dispatch, getState) => {
const state = getState();
const instance = _getInstance(state)(instanceName) || {};
const update = async () => {
const configPath = path.join(
_getInstancesPath(state),
instanceName,
'config.json'
);
const tempConfigPath = path.join(
_getInstancesPath(state),
instanceName,
'config_new_temp.json'
);
// Remove queue and name, they are augmented in the reducer and we don't want them in the config file
const newConfig = updateFunction(omit(instance, ['queue', 'name']));
const JsonString = JSON.stringify(newConfig);
// Ensure that the new config is actually valid to write
try {
const isJson = JSON.parse(JsonString);
if (!isJson || typeof isJson !== 'object') {
const err = `Cannot write this JSON to ${instanceName}. Not an object`;
log.error(err);
throw new Error(err);
}
} catch {
const err = `Cannot write this JSON to ${instanceName}. Not parsable`;
log.error(err, newConfig);
throw new Error(err);
}
const writeFileToDisk = async (content, tempP, p) => {
await new Promise((resolve, reject) => {
fss.open(tempP, 'w', async (err, fd) => {
if (err || !fd) reject(err);
const buffer = Buffer.from(content);
fss.write(
fd,
buffer,
0,
buffer.length,
null,
(err1, bytesWritten, writtenBuffer) => {
if (err1) reject(err1);
if (
buffer.length !== bytesWritten ||
Buffer.compare(buffer, writtenBuffer) !== 0
) {
reject(new Error('Content corrupted'));
}
fss.close(fd, () => resolve());
}
);
});
});
const readBuff = Buffer.alloc(50);
const newFile = await fs.open(tempP, 'r');
await newFile.read(readBuff, 0, 50, null);
if (readBuff.every(v => v === 0)) {
throw new Error('Corrupted file');
}
newFile.close();
await fs.rename(tempP, p);
};
try {
await fs.access(configPath);
await writeFileToDisk(JsonString, tempConfigPath, configPath);
} catch {
if (forceWrite) {
await writeFileToDisk(JsonString, tempConfigPath, configPath);
}
}
dispatch({
type: ActionTypes.UPDATE_INSTANCES,
instances: {
...state.instances.list,
[instanceName]: updateFunction(instance)
}
});
};
if (instance?.queue) {
// Add it to the instance promise queue
await instance.queue.add(update);
} else {
await update();
}
};
}
export function addToQueue(
instanceName,
loader,
manifest,
background,
timePlayed,
settings = {},
updateOptions
) {
return async (dispatch, getState) => {
const state = getState();
const { currentDownload } = state;
const patchedSettings =
typeof settings === 'object' && settings !== null ? settings : {};
const { isUpdate, bypassCopy } = updateOptions || {};
dispatch({
type: ActionTypes.ADD_DOWNLOAD_TO_QUEUE,
instanceName,
loader,
manifest,
background,
isUpdate,
bypassCopy,
...patchedSettings
});
await makeDir(path.join(_getInstancesPath(state), instanceName));
lockfile.lock(
path.join(_getInstancesPath(state), instanceName, 'installing.lock'),
err => {
if (err) console.error(err);
}
);
dispatch(
updateInstanceConfig(
instanceName,
prev => {
return {
...(prev || {}),
loader,
timePlayed: prev.timePlayed || timePlayed || 0,
background,
mods: prev.mods || [],
...patchedSettings
};
},
true
)
);
if (!currentDownload) {
dispatch(updateCurrentDownload(instanceName));
dispatch(downloadInstance(instanceName));
}
};
}
export function addNextInstanceToCurrentDownload() {
return (dispatch, getState) => {
const { downloadQueue } = getState();
const queueArr = Object.keys(downloadQueue);
if (queueArr.length > 0) {
dispatch(updateCurrentDownload(queueArr[0]));
dispatch(downloadInstance(queueArr[0]));
}
};
}
export function downloadFabric(instanceName) {
return async (dispatch, getState) => {
const state = getState();
const { loader } = _getCurrentDownloadItem(state);
dispatch(updateDownloadStatus(instanceName, 'Downloading fabric files...'));
let fabricJson;
const fabricJsonPath = path.join(
_getLibrariesPath(state),
'net',
'fabricmc',
loader?.mcVersion,
loader?.loaderVersion,
'fabric.json'
);
try {
fabricJson = await fse.readJson(fabricJsonPath);
} catch (err) {
fabricJson = (await getFabricJson(loader)).data;
await fse.outputJson(fabricJsonPath, fabricJson);
}
const libraries = librariesMapper(
fabricJson.libraries,
_getLibrariesPath(state)
);
let prev = 0;
const updatePercentage = downloaded => {
const percentage = (downloaded * 100) / libraries.length;
const progress = parseInt(percentage, 10);
if (progress !== prev) {
prev = progress;
dispatch(updateDownloadProgress(progress));
}
};
await downloadInstanceFiles(
libraries,
updatePercentage,
state.settings.concurrentDownloads
);
};
}
export function downloadForge(instanceName) {
return async (dispatch, getState) => {
const state = getState();
const { loader } = _getCurrentDownloadItem(state);
const forgeJson = {};
const forgeJsonPath = path.join(
_getLibrariesPath(state),
'net',
'minecraftforge',
loader?.loaderVersion,
`${loader?.loaderVersion}.json`
);
const mcJsonPath = path.join(
_getMinecraftVersionsPath(state),
`${loader?.mcVersion}.json`
);
const sevenZipPath = await get7zPath();
const pre152 = lte(coerce(loader?.mcVersion), coerce('1.5.2'));
const pre132 = lte(coerce(loader?.mcVersion), coerce('1.3.2'));
const baseUrl = 'https://maven.minecraftforge.net/net/minecraftforge/forge';
const tempInstaller = path.join(
_getTempPath(state),
`${loader?.loaderVersion}.jar`
);
const expectedInstaller = path.join(
_getDataStorePath(state),
'forgeInstallers',
`${loader?.loaderVersion}.jar`
);
const extractSpecificFile = async from => {
await extractAll(tempInstaller, _getTempPath(state), {
$cherryPick: from
});
};
try {
await fs.access(expectedInstaller);
if (!pre152) {
await fs.access(forgeJsonPath);
}
const { data: hashes } = await axios.get(
`https://files.minecraftforge.net/net/minecraftforge/forge/${loader?.loaderVersion}/meta.json`
);
const fileMd5 = await getFileHash(expectedInstaller, 'md5');
let expectedMd5 = hashes?.classifiers?.installer?.jar;
if (pre132) {
expectedMd5 = hashes?.classifiers?.client?.zip;
} else if (pre152) {
expectedMd5 = hashes?.classifiers?.universal?.zip;
}
if (fileMd5.toString() !== expectedMd5) {
throw new Error('Installer hash mismatch');
}
await fse.copy(expectedInstaller, tempInstaller, { overwrite: true });
} catch (err) {
console.warn(
'No installer found in temp or hash mismatch. Need to download it.'
);
dispatch(
updateDownloadStatus(instanceName, 'Downloading forge installer...')
);
let urlTerminal = 'installer.jar';
if (pre132) {
urlTerminal = 'client.zip';
} else if (pre152) {
urlTerminal = 'universal.zip';
}
// Download installer jar and extract stuff
let prev = 0;
await downloadFile(
tempInstaller,
`${baseUrl}/${loader?.loaderVersion}/forge-${loader?.loaderVersion}-${urlTerminal}`,
p => {
const progress = parseInt(p, 10) / 100;
if (progress !== prev) {
prev = progress;
dispatch(updateDownloadProgress(p));
}
}
);
await new Promise(resolve => setTimeout(resolve, 200));
await fse.copy(tempInstaller, expectedInstaller);
}
const installForgePost152 = async () => {
// Extract version / install json, main jar, universal and client lzma
await extractSpecificFile('install_profile.json');
const installerJson = await fse.readJson(
path.join(_getTempPath(state), 'install_profile.json')
);
if (installerJson.install) {
forgeJson.install = installerJson.install;
forgeJson.version = installerJson.versionInfo;
} else {
forgeJson.install = installerJson;
await extractSpecificFile(path.basename(installerJson.json));
forgeJson.version = await fse.readJson(
path.join(_getTempPath(state), installerJson.json)
);
await fse.remove(path.join(_getTempPath(state), installerJson.json));
}
await fse.remove(path.join(_getTempPath(state), 'install_profile.json'));
await fse.outputJson(forgeJsonPath, forgeJson);
let skipForgeFilter = true;
// Extract forge bin
if (forgeJson.install.filePath) {
await extractSpecificFile(forgeJson.install.filePath);
await fse.move(
path.join(_getTempPath(state), forgeJson.install.filePath),
path.join(
_getLibrariesPath(state),
...mavenToArray(forgeJson.install.path)
),
{ overwrite: true }
);
} else if (forgeJson.install.path) {
// Move all files in maven
const forgeBinPathInsideZip = path.join(
'maven',
path.dirname(path.join(...mavenToArray(forgeJson.install.path)))
);
await extractSpecificFile(forgeBinPathInsideZip);
const filesToMove = await fs.readdir(
path.join(_getTempPath(state), forgeBinPathInsideZip)
);
await Promise.all(
filesToMove.map(async f => {
await fse.move(
path.join(_getTempPath(state), forgeBinPathInsideZip, f),
path.join(
_getLibrariesPath(state),
path.dirname(
path.join(...mavenToArray(forgeJson.install.path))
),
path.basename(f)
),
{ overwrite: true }
);
})
);
await fse.remove(path.join(_getTempPath(state), 'maven'));
} else {
// Forge 1.17+
skipForgeFilter = false;
}
dispatch(
updateDownloadStatus(instanceName, 'Downloading forge libraries...')
);
let { libraries } = forgeJson.version;
if (forgeJson.install.libraries) {
libraries = libraries.concat(forgeJson.install.libraries);
}
libraries = librariesMapper(
libraries.filter(
v =>
!skipForgeFilter ||
(!v.name.includes('net.minecraftforge:forge:') &&
!v.name.includes('net.minecraftforge:minecraftforge:'))
),
_getLibrariesPath(state)
);
let prev = 0;
const updatePercentage = downloaded => {
const percentage = (downloaded * 100) / libraries.length;
const progress = parseInt(percentage, 10);
if (progress !== prev) {
prev = progress;
dispatch(updateDownloadProgress(progress));
}
};
await downloadInstanceFiles(
libraries,
updatePercentage,
state.settings.concurrentDownloads
);
// Patching
if (forgeJson.install?.processors?.length) {
dispatch(updateDownloadStatus(instanceName, 'Patching forge...'));
// Extract client.lzma from installer
await extractSpecificFile(path.join('data', 'client.lzma'));
const universalPath = forgeJson.install.libraries.find(v =>
(v.name || '').startsWith('net.minecraftforge:forge')
)?.name;
await fse.move(
path.join(_getTempPath(state), 'data', 'client.lzma'),
path.join(
_getLibrariesPath(state),
...mavenToArray(
forgeJson.install.path || universalPath,
'-clientdata',
'.lzma'
)
),
{ overwrite: true }
);
await fse.remove(path.join(_getTempPath(state), 'data'));
await patchForge113(
forgeJson.install,
path.join(
_getMinecraftVersionsPath(state),
`${forgeJson.install.minecraft}.jar`
),
_getLibrariesPath(state),
expectedInstaller,
mcJsonPath,
universalPath,
_getJavaPath(state)(8),
(d, t) => dispatch(updateDownloadProgress((d * 100) / t))
);
}
};
if (gt(coerce(loader?.mcVersion), coerce('1.5.2'))) {
await installForgePost152();
} else {
// Download necessary libs
const fmllibs = fmlLibsMapping[loader?.mcVersion];
await pMap(
fmllibs || [],
async lib => {
let ok = false;
let tries = 0;
do {
tries += 1;
if (tries !== 1) {
await new Promise(resolve => setTimeout(resolve, 5000));
}
try {
const fileName = path.join(
_getInstancesPath(state),
instanceName,
'lib',
lib[0]
);
const baseFmlUrl = lib[2]
? FMLLIBS_OUR_BASE_URL
: FMLLIBS_FORGE_BASE_URL;
const url = `${baseFmlUrl}/${lib[0]}`;
await downloadFile(fileName, url);
const fileHash = await getFileHash(fileName);
if (lib[1] !== fileHash.toString()) {
throw new Error(`FMLLIB hash mismatch (${lib[0]})`);
}
ok = true;
} catch (err) {
console.error(err);
}
} while (!ok && tries <= 3);
},
{ concurrency: state.settings.concurrentDownloads }
);
dispatch(updateDownloadStatus(instanceName, 'Injecting forge...'));
dispatch(updateDownloadProgress(0));
// Perform forge injection
const mcJarPath = path.join(
_getMinecraftVersionsPath(state),
`${loader?.mcVersion}.jar`
);
const mcJarForgePath = path.join(
_getMinecraftVersionsPath(state),
`${loader?.loaderVersion}.jar`
);
await fse.copy(mcJarPath, mcJarForgePath);
const metaInfDeletion = Seven.delete(mcJarForgePath, 'META-INF', {
$bin: sevenZipPath,
yes: true,
$spawnOptions: { shell: true }
});
await new Promise((resolve, reject) => {
metaInfDeletion.on('end', () => {
resolve();
});
metaInfDeletion.on('error', error => {
reject(error.stderr);
});
});
await fse.remove(path.join(_getTempPath(state), loader?.loaderVersion));
// This is garbage, need to use a stream somehow to directly inject data from/to jar
await extractAll(
tempInstaller,
path.join(_getTempPath(state), loader?.loaderVersion)
);
dispatch(updateDownloadProgress(50));
const updatedFiles = Seven.add(
mcJarForgePath,
`${path.join(_getTempPath(state), loader?.loaderVersion)}/*`,
{
$bin: sevenZipPath,
yes: true,
$spawnOptions: { shell: true }
}
);
await new Promise((resolve, reject) => {
updatedFiles.on('end', () => {
resolve();
});
updatedFiles.on('error', error => {
reject(error.stderr);
});
});
await fse.remove(path.join(_getTempPath(state), loader?.loaderVersion));
}
await fse.remove(tempInstaller);
};
}
export function processForgeManifest(instanceName) {
return async (dispatch, getState) => {
const state = getState();
const { manifest, loader } = _getCurrentDownloadItem(state);
const concurrency = state.settings.concurrentDownloads;
dispatch(updateDownloadStatus(instanceName, 'Downloading mods...'));
const addonsHashmap = {};
const addonsFilesHashmap = {};
const _getAddons = async () => {
const addons = await getMultipleAddons(
manifest.files.map(v => v.projectID)
);
addons.forEach(v => {
addonsHashmap[v.id] = v;
});
};
const _getAddonFiles = async () => {
await pMap(
manifest.files,
async item => {
const modManifest = await getAddonFile(item.projectID, item.fileID);
addonsFilesHashmap[item.projectID] = modManifest;
},
{ concurrency: concurrency + 10 }
);
};
await Promise.all([_getAddons(), _getAddonFiles()]);
let modManifests = [];
const optedOutMods = [];
await pMap(
manifest.files,
async item => {
if (!addonsHashmap[item.projectID]) return;
let ok = false;
let tries = 0;
/* eslint-disable no-await-in-loop */
do {
tries += 1;
if (tries !== 1) {
await new Promise(resolve => setTimeout(resolve, 5000));
}
const addon = addonsHashmap[item.projectID];
const isResourcePack = addon.classId === 12;
const modManifest = addonsFilesHashmap[item.projectID];
const destFile = path.join(
_getInstancesPath(state),
instanceName,
isResourcePack ? 'resourcepacks' : 'mods',
modManifest.fileName
);
const fileExists = await fse.pathExists(destFile);
if (!fileExists) {
if (!modManifest.downloadUrl) {
const normalizedModData = normalizeModData(
modManifest,
item.projectID,
addon.name
);
optedOutMods.push({ addon, modManifest: normalizedModData });
return;
}
await downloadFile(destFile, modManifest.downloadUrl);
modManifests = modManifests.concat(
normalizeModData(modManifest, item.projectID, addon.name)
);
}
const percentage =
(modManifests.length * 100) / manifest.files.length - 1;
dispatch(updateDownloadProgress(percentage > 0 ? percentage : 0));
ok = true;
} while (!ok && tries <= 3);
/* eslint-enable no-await-in-loop */
},
{ concurrency }
);
if (optedOutMods.length) {
await new Promise((resolve, reject) => {
dispatch(
openModal('OptedOutModsList', {
optedOutMods,
instancePath: path.join(_getInstancesPath(state), instanceName),
resolve,
reject,
abortCallback: () => {
setTimeout(
() => reject(new Error('Download Aborted by the user')),
300
);
}
})
);
});
}
modManifests = modManifests.concat(
...optedOutMods.map(v =>
normalizeModData(v.modManifest, v.modManifest.projectID, v.addon.name)
)
);
let validAddon = false;
const addonPathZip = path.join(
_getTempPath(state),
instanceName,
'addon.zip'
);
try {
await fs.stat(addonPathZip);
validAddon = true;
} catch {
// If project and file id are provided, we download it on the spot
if (loader.projectID && loader.fileID) {
const data = await getAddonFile(loader.projectID, loader.fileID);
try {
await downloadFile(addonPathZip, data.downloadUrl);
validAddon = true;
} catch {
// NO-OP
}
}
}
if (validAddon) {
dispatch(updateDownloadStatus(instanceName, 'Copying overrides...'));
let progress = 0;
await extractAll(
addonPathZip,
path.join(_getTempPath(state), instanceName),
{
recursive: true,
$cherryPick: 'overrides',
$progress: true
},
{
progress: percent => {
if (percent !== progress) {
progress = percent;
dispatch(updateDownloadProgress(percent));
}
}
}
);
dispatch(updateDownloadStatus(instanceName, 'Finalizing overrides...'));
const overrideFiles = await getFilesRecursive(
path.join(_getTempPath(state), instanceName, 'overrides')
);
await dispatch(
updateInstanceConfig(instanceName, config => {
return {
...config,
mods: [...(config.mods || []), ...modManifests],
overrides: overrideFiles.map(v =>
path.relative(
path.join(_getTempPath(state), instanceName, 'overrides'),
v
)
)
};
})
);
await new Promise(resolve => {
// Force premature unlock to let our listener catch mods from override
lockfile.unlock(
path.join(
_getInstancesPath(getState()),
instanceName,
'installing.lock'
),
err => {
if (err) console.error(err);
resolve();
}
);
});
await Promise.all(
overrideFiles.map(v => {
const relativePath = path.relative(
path.join(_getTempPath(state), instanceName, 'overrides'),
v
);
const newPath = path.join(
_getInstancesPath(state),
instanceName,
relativePath
);
return fse.copy(v, newPath, { overwrite: true });
})
);
await fse.remove(addonPathZip);
} else {
await new Promise(resolve => {
// Force premature unlock to let our listener catch mods from override
lockfile.unlock(
path.join(
_getInstancesPath(getState()),
instanceName,
'installing.lock'
),
err => {
if (err) console.error(err);
resolve();
}
);
});
}
await fse.remove(path.join(_getTempPath(state), instanceName));
};
}
export function downloadInstance(instanceName) {
return async (dispatch, getState) => {
const state = getState();
const { loader, manifest, isUpdate, bypassCopy } =
_getCurrentDownloadItem(state);
const {
app: {
vanillaManifest: { versions: mcVersions }
}
} = state;
const tempPath = _getTempPath(state);
const tempInstancePath = path.join(tempPath, `${instanceName}__RESTORE`);
try {
const instancesPath = _getInstancesPath(getState());
if (isUpdate && !bypassCopy) {
dispatch(
updateDownloadStatus(instanceName, 'Creating restore point...')
);
const oldInstancePath = path.join(instancesPath, instanceName);
const sizeSrc = await getSize(oldInstancePath);
const interval = setInterval(async () => {
try {
const sizeDest = await getSize(tempInstancePath);
const progress = (100 * sizeDest) / sizeSrc;
dispatch(updateDownloadProgress(progress));
} catch {
// Doesn't matter too much
}
}, 400);
try {
await makeInstanceRestorePoint(
tempInstancePath,
instancesPath,
instanceName
);
clearInterval(interval);
} catch (e) {
console.warn(e);
clearInterval(interval);
}
}
dispatch(updateDownloadStatus(instanceName, 'Downloading game files...'));
const mcVersion = loader?.mcVersion;
let mcJson;
// DOWNLOAD MINECRAFT JSON
const mcJsonPath = path.join(
_getMinecraftVersionsPath(state),
`${mcVersion}.json`
);
try {
mcJson = await fse.readJson(mcJsonPath);
} catch (err) {
const versionURL = mcVersions.find(v => v.id === mcVersion).url;
mcJson = (await axios.get(versionURL)).data;
await fse.outputJson(mcJsonPath, mcJson);
}
// COMPUTING MC ASSETS
let assetsJson;
const assetsFile = path.join(
_getAssetsPath(state),
'indexes',
`${mcJson.assets}.json`
);
try {
assetsJson = await fse.readJson(assetsFile);
} catch (e) {
assetsJson = (await axios.get(mcJson.assetIndex.url)).data;
await fse.outputJson(assetsFile, assetsJson);
}
const mcMainFile = {
url: mcJson.downloads.client.url,
sha1: mcJson.downloads.client.sha1,
path: path.join(_getMinecraftVersionsPath(state), `${mcJson.id}.jar`)
};
const assets = Object.entries(assetsJson.objects).map(
([assetKey, { hash }]) => ({
url: `${MC_RESOURCES_URL}/${hash.substring(0, 2)}/${hash}`,
type: 'asset',
sha1: hash,
path: path.join(
_getAssetsPath(state),
'objects',
hash.substring(0, 2),
hash
),
resourcesPath: path.join(
_getInstancesPath(state),
instanceName,
'resources',
assetKey
),
legacyPath: path.join(
_getAssetsPath(state),
'virtual',
'legacy',
assetKey
)
})
);
if (mcJson.logging) {
const {
sha1: loggingHash,
id: loggingId,
url: loggingUrl
} = mcJson.logging.client.file;
await downloadFile(
path.join(
_getAssetsPath(state),
'objects',
loggingHash.substring(0, 2),
loggingId
),
loggingUrl
);
}
const libraries = librariesMapper(
mcJson.libraries,
_getLibrariesPath(state)
);
let prev = 0;
const updatePercentage = downloaded => {
const percentage =
(downloaded * 100) / (assets.length + libraries.length + 1);
const progress = parseInt(percentage, 10);
if (progress !== prev) {
prev = progress;
dispatch(updateDownloadProgress(progress));
}
};
await downloadInstanceFiles(
[...libraries, ...assets, mcMainFile],
updatePercentage,
state.settings.concurrentDownloads
);
// Wait 400ms to avoid "The process cannot access the file because it is being used by another process."
await new Promise(resolve => setTimeout(() => resolve(), 1000));
await extractNatives(
libraries,
path.join(_getInstancesPath(state), instanceName)
);
if (assetsJson.map_to_resources) {
await copyAssetsToResources(assets);
}
if (mcJson.assets === 'legacy') {
await copyAssetsToLegacy(assets);
}
if (loader?.loaderType === FABRIC) {
await dispatch(downloadFabric(instanceName));
} else if (loader?.loaderType === FORGE) {
await dispatch(downloadForge(instanceName));
}
// analyze source for forge
if (manifest && loader?.source === CURSEFORGE)
await dispatch(processForgeManifest(instanceName));
dispatch(updateDownloadProgress(0));
// Be aware that from this line the installer lock might be unlocked!
await dispatch(removeDownloadFromQueue(instanceName));
dispatch(addNextInstanceToCurrentDownload());
await remove(tempInstancePath);
} catch (err) {
console.error(err);
// Show error modal and decide what to do
dispatch(
openModal('InstanceDownloadFailed', {
instanceName,
preventClose: true,
error: err,
isUpdate
})
);
}
};
}
export const changeModpackVersion = (instanceName, newModpackData) => {
return async (dispatch, getState) => {
const state = getState();
const instance = _getInstance(state)(instanceName);
const tempPath = _getTempPath(state);
const instancePath = path.join(_getInstancesPath(state), instanceName);
if (instance.loader.source === CURSEFORGE) {
const addon = await getAddon(instance.loader?.projectID);
const manifest = await fse.readJson(
path.join(instancePath, 'manifest.json')
);
await fse.remove(path.join(instancePath, 'manifest.json'));
// Delete prev overrides
await Promise.all(
(instance?.overrides || []).map(async v => {
try {
await fs.stat(path.join(instancePath, v));
await fse.remove(path.join(instancePath, v));
} catch {
// Swallow error
}
})
);
const modsprojectIds = (manifest?.files || []).map(v => v?.projectID);
dispatch(
updateInstanceConfig(instanceName, prev =>
omit(
{
...prev,
mods: prev.mods.filter(
v => !modsprojectIds.includes(v?.projectID)
)
},
['overrides']
)
)
);
await Promise.all(
modsprojectIds.map(async projectID => {
const modFound = instance.mods?.find(v => v?.projectID === projectID);
if (modFound?.fileName) {
try {
await fs.stat(
path.join(instancePath, 'mods', modFound?.fileName)
);
await fse.remove(
path.join(instancePath, 'mods', modFound?.fileName)
);
} catch {
// Swallow error
}
}
})
);
const imageURL = addon?.logo?.thumbnailUrl;
const newManifest = await downloadAddonZip(
instance.loader?.projectID,
newModpackData.id,
path.join(_getInstancesPath(state), instanceName),
path.join(tempPath, instanceName)
);
await downloadFile(
path.join(
_getInstancesPath(state),
instanceName,
`background${path.extname(imageURL)}`
),
imageURL
);
let loaderVersion;
if (instance.loader?.loaderType === FABRIC) {
loaderVersion = extractFabricVersionFromManifest(newManifest);
} else {
loaderVersion = convertcurseForgeToCanonical(
newManifest.minecraft.modLoaders.find(v => v.primary).id,
newManifest.minecraft.version,
state.app.forgeManifest
);
}
const loader = {
loaderType: instance.loader?.loaderType,
mcVersion: newManifest.minecraft.version,
loaderVersion,
fileID: instance.loader?.fileID,
projectID: instance.loader?.projectID,
source: instance.loader?.source
};
dispatch(
addToQueue(
instanceName,
loader,
newManifest,
`background${path.extname(imageURL)}`,
undefined,
undefined,
{ isUpdate: true, bypassCopy: true }
)
);
}
};
};
export const startListener = () => {
return async (dispatch, getState) => {
// Real Time Scanner
const state = getState();
const instancesPath = _getInstancesPath(state);
const Queue = new PromiseQueue();
Queue.on('start', queueLength => {
if (queueLength > 1) {
dispatch(
updateMessage({
content: `Synchronizing mods. ${queueLength} left.`,
duration: 0
})
);
}
});
Queue.on('executed', queueLength => {
if (queueLength > 1) {
dispatch(
updateMessage({
content: `Synchronizing mods. ${queueLength} left.`,
duration: 0
})
);
}
});
Queue.on('end', () => {
dispatch(updateMessage(null));
});
const changesTracker = {};
const processAddedFile = async (fileName, instanceName) => {
const processChange = async () => {
const newState = getState();
const instance = _getInstance(newState)(instanceName);
const isInConfig = (instance?.mods || []).find(
mod => mod.fileName === path.basename(fileName)
);
try {
const stat = await fs.lstat(fileName);
if (instance?.mods && !isInConfig && stat.isFile() && instance) {
// get murmur hash
const murmurHash = await getFileMurmurHash2(fileName);
const data = await getAddonsByFingerprint([murmurHash]);
const exactMatch = (data.exactMatches || [])[0];
const notMatch = (data.unmatchedFingerprints || [])[0];
let mod = {};
if (exactMatch) {
let addon = null;
try {
addon = await getAddon(exactMatch.file.modId);
mod = normalizeModData(
exactMatch.file,
exactMatch.file.modId,
addon.name
);
mod.fileName = path.basename(fileName);
} catch {
mod = {
fileName: path.basename(fileName),
displayName: path.basename(fileName),
packageFingerprint: murmurHash
};
}
} else if (notMatch) {
mod = {
fileName: path.basename(fileName),
displayName: path.basename(fileName),
packageFingerprint: murmurHash
};
}
const updatedInstance = _getInstance(getState())(instanceName);
const isStillNotInConfig = !(updatedInstance?.mods || []).find(
m => m.fileName === path.basename(fileName)
);
if (isStillNotInConfig && updatedInstance) {
console.log('[RTS] ADDING MOD', fileName, instanceName);
await dispatch(
updateInstanceConfig(instanceName, prev => ({
...prev,
mods: [...(prev.mods || []), mod]
}))
);
}
}
} catch (err) {
console.error(err);
}
};
Queue.add(processChange);
};
const processRemovedFile = async (fileName, instanceName) => {
const processChange = async () => {
const instance = getState().instances.list[instanceName];
const isInConfig = (instance?.mods || []).find(
mod => mod.fileName === path.basename(fileName)
);
if (isInConfig) {
try {
console.log('[RTS] REMOVING MOD', fileName, instanceName);
await dispatch(
updateInstanceConfig(instanceName, prev => ({
...prev,
mods: (prev.mods || []).filter(
m => m.fileName !== path.basename(fileName)
)
}))
);
} catch (err) {
console.error(err);
}
}
};
Queue.add(processChange);
};
const processRenamedFile = async (
fileName,
oldInstanceName,
newFilePath
) => {
const processChange = async () => {
const newState = getState();
const instances = newState.instances.list;
const modData = (instances[oldInstanceName].mods || []).find(
m => m.fileName === path.basename(fileName)
);
if (modData) {
try {
console.log('[RTS] RENAMING MOD', fileName, newFilePath, modData);
await dispatch(
updateInstanceConfig(oldInstanceName, prev => ({
...prev,
mods: [
...(prev.mods || []).filter(
m => m.fileName !== path.basename(fileName)
),
{ ...modData, fileName: path.basename(newFilePath) }
]
}))
);
} catch (err) {
console.error(err);
}
}
};
Queue.add(processChange);
};
const processAddedInstance = async instanceName => {
const processChange = async () => {
const newState = getState();
const instance = _getInstance(newState)(instanceName);
if (!instance) {
const configPath = path.join(
instancesPath,
instanceName,
'config.json'
);
try {
const config = await fse.readJSON(configPath);
if (!config.loader) {
throw new Error(`Config for ${instanceName} could not be parsed`);
}
console.log('[RTS] ADDING INSTANCE', instanceName);
dispatch({
type: ActionTypes.UPDATE_INSTANCES,
instances: {
...newState.instances.list,
[instanceName]: { ...config, name: instanceName }
}
});
} catch (err) {
console.warn(err);
}
}
};
Queue.add(processChange);
};
const processRemovedInstance = instanceName => {
const processChange = async () => {
const newState = getState();
if (_getInstance(newState)(instanceName)) {
console.log('[RTS] REMOVING INSTANCE', instanceName);
dispatch({
type: ActionTypes.UPDATE_INSTANCES,
instances: omit(newState.instances.list, [instanceName])
});
}
};
Queue.add(processChange);
};
const processRenamedInstance = async (oldInstanceName, newInstanceName) => {
const processChange = async () => {
const newState = getState();
const instance = _getInstance(newState)(newInstanceName);
if (!instance) {
try {
const configPath = path.join(
instancesPath,
newInstanceName,
'config.json'
);
const config = await fse.readJSON(configPath);
if (!config.loader) {
throw new Error(
`Config for ${newInstanceName} could not be parsed`
);
}
console.log(
`[RTS] RENAMING INSTANCE ${oldInstanceName} -> ${newInstanceName}`
);
dispatch({
type: ActionTypes.UPDATE_INSTANCES,
instances: {
...omit(newState.instances.list, [oldInstanceName]),
[newInstanceName]: { ...config, name: newInstanceName }
}
});
const instanceManagerModalIndex = newState.modals.findIndex(
x =>
x.modalType === 'InstanceManager' &&
x.modalProps.instanceName === oldInstanceName
);
dispatch({
type: UPDATE_MODAL,
modals: [
...newState.modals.slice(0, instanceManagerModalIndex),
{
modalType: 'InstanceManager',
modalProps: { instanceName: newInstanceName }
},
...newState.modals.slice(instanceManagerModalIndex + 1)
]
});
} catch (err) {
console.error(err);
}
}
};
Queue.add(processChange);
};
ipcRenderer.on('listener-events', async (e, events) => {
await Promise.all(
events.map(async event => {
// Using oldFile instead of newFile is intentional.
// This is used to discard the ADD action dispatched alongside
// the rename action.
const completePath = path.join(
event.directory,
event.file || event.oldFile
);
const isRename = event.newFile && event.oldFile;
if (
(!isMod(completePath, instancesPath) &&
!isInstanceFolderPath(completePath, instancesPath) &&
!isRename) ||
// When renaming, an ADD action is dispatched too. Try to discard that
(event.action !== 2 && changesTracker[completePath]) ||
// Ignore java legacy fixer
path.basename(completePath) === '__JLF__.jar'
) {
return;
}
if (event.action !== 2 && !changesTracker[completePath]) {
// If we cannot find it in the hash table, it's a new event
changesTracker[completePath] = {
action: event.action,
completed:
event.action !== 0 ||
(event.action === 0 &&
isInstanceFolderPath(completePath, instancesPath)),
...(event.action === 3 && {
newFilePath: path.join(event.newDirectory, event.newFile)
})
};
}
if (
changesTracker[completePath] &&
!changesTracker[completePath].completed &&
(event.action === 2 || event.action === 0 || event.action === 1)
) {
let filehandle;
try {
await new Promise(resolve => setTimeout(resolve, 300));
filehandle = await fs.open(completePath, 'r+');
changesTracker[completePath].completed = true;
} catch {
// Do nothing, simply not completed..
} finally {
await filehandle?.close();
}
}
})
);
// Handle edge case where MOD-REMOVE is called before INSTANCE-REMOVE
Object.entries(changesTracker).forEach(
async ([fileName, { action, completed }]) => {
if (
isInstanceFolderPath(fileName, instancesPath) &&
action === 1 &&
completed
) {
const instanceName = convertCompletePathToInstance(
fileName,
instancesPath
)
.substr(1)
.split(path.sep)[0];
// Check if we can find any other action with this instance name
Object.entries(changesTracker).forEach(
([file, { action: act }]) => {
if (isMod(file, instancesPath) && act === 1) {
const instName = convertCompletePathToInstance(
file,
instancesPath
)
.substr(1)
.split(path.sep)[0];
if (instanceName === instName) {
delete changesTracker[file];
}
}
}
);
}
}
);
Object.entries(changesTracker).map(
async ([fileName, { action, completed, newFilePath }]) => {
const filePath = newFilePath || fileName;
// Events are dispatched 3 times. Wait for 3 dispatches to be sure
// that the action was completely executed
if (completed) {
// Remove the current file from the tracker.
// Using fileName instead of filePath is intentional for the RENAME/ADD issue
delete changesTracker[fileName];
// Infer the instance name from the full path
const instanceName = convertCompletePathToInstance(
filePath,
instancesPath
)
.substr(1)
.split(path.sep)[0];
// If we're installing a modpack we don't want to process anything
const isLocked = await new Promise((resolve, reject) => {
lockfile.check(
path.join(instancesPath, instanceName, 'installing.lock'),
(err, locked) => {
if (err) reject(err);
resolve(locked);
}
);
});
if (isLocked) return;
if (
isMod(fileName, instancesPath) &&
_getInstance(getState())(instanceName) &&
action !== 3
) {
if (action === 0) {
processAddedFile(filePath, instanceName);
} else if (action === 1) {
processRemovedFile(filePath, instanceName);
}
} else if (
action === 3 &&
!isInstanceFolderPath(fileName, instancesPath) &&
!isInstanceFolderPath(newFilePath, instancesPath)
) {
// Infer the instance name from the full path
const oldInstanceName = convertCompletePathToInstance(
fileName,
instancesPath
)
.substr(1)
.split(path.sep)[0];
if (
oldInstanceName === instanceName &&
isMod(newFilePath, instancesPath) &&
isMod(fileName, instancesPath)
) {
processRenamedFile(fileName, instanceName, newFilePath);
} else if (
oldInstanceName !== instanceName &&
isMod(newFilePath, instancesPath) &&
isMod(fileName, instancesPath)
) {
processRemovedFile(fileName, oldInstanceName);
processAddedFile(newFilePath, instanceName);
} else if (
!isMod(newFilePath, instancesPath) &&
isMod(fileName, instancesPath)
) {
processRemovedFile(fileName, oldInstanceName);
} else if (
isMod(newFilePath, instancesPath) &&
!isMod(fileName, instancesPath)
) {
processAddedFile(newFilePath, instanceName);
}
} else if (isInstanceFolderPath(filePath, instancesPath)) {
if (action === 0) {
processAddedInstance(instanceName);
} else if (action === 1) {
processRemovedInstance(instanceName);
} else if (action === 3) {
const oldInstanceName = convertCompletePathToInstance(
fileName,
instancesPath
)
.substr(1)
.split(path.sep)[0];
processRenamedInstance(oldInstanceName, instanceName);
}
}
}
}
);
});
await ipcRenderer.invoke('start-listener', instancesPath);
};
};
export function getJavaVersionForMCVersion(mcVersion) {
return (_, getState) => {
const { app } = getState();
const { versions } = app?.vanillaManifest || {};
if (versions) {
const version = versions.find(v => v.id === mcVersion);
const javaLatestInitialDate = new Date('2021-05-27T09:39:21+00:00');
if (new Date(version?.releaseTime) < javaLatestInitialDate) {
return 8;
}
}
return LATEST_JAVA_VERSION;
};
}
export function launchInstance(instanceName, forceQuit = false) {
return async (dispatch, getState) => {
const state = getState();
const { userData } = state;
const account = _getCurrentAccount(state);
const librariesPath = _getLibrariesPath(state);
const assetsPath = _getAssetsPath(state);
const { memory, args } = state.settings.java;
const { resolution: globalMinecraftResolution } =
state.settings.minecraftSettings;
const instanceState = _getInstance(state)(instanceName);
const {
loader,
javaArgs,
javaMemory,
customJavaPath,
resolution: instanceResolution
} = instanceState;
const mcJsonPath = path.join(
_getMinecraftVersionsPath(state),
`${loader?.mcVersion}.json`
);
let discordRPCDetails = `Minecraft ${loader?.mcVersion}`;
if (loader.source && loader.sourceName) {
discordRPCDetails = `${loader.sourceName}`;
}
ipcRenderer.invoke('update-discord-rpc', discordRPCDetails);
const defaultJavaPathVersion = _getJavaPath(state)(
dispatch(getJavaVersionForMCVersion(loader?.mcVersion))
);
const javaPath = customJavaPath || defaultJavaPathVersion;
let missingResource = false;
const verifyResource = async resourcePath => {
if (forceQuit) return true;
try {
await fs.access(resourcePath);
return true;
} catch {
console.warn(`Missing resource: ${resourcePath}`);
dispatch(
addToQueue(
instanceName,
instanceState.loader,
null,
instanceState.background
)
);
await new Promise(resolve => {
const unsubscribe = window.__store.subscribe(() => {
if (!getState().downloadQueue[instanceName]) {
unsubscribe();
return resolve();
}
});
});
dispatch(launchInstance(instanceName));
return false;
}
};
const instancePath = path.join(_getInstancesPath(state), instanceName);
const configPath = path.join(instancePath, 'config.json');
const backupConfigPath = path.join(instancePath, 'config.bak.json');
await fs.copyFile(configPath, backupConfigPath);
const instanceJLFPath = path.join(
_getInstancesPath(state),
instanceName,
'mods',
'__JLF__.jar'
);
let errorLogs = '';
// Verify main jar JSON
let verified = await verifyResource(mcJsonPath);
if (!verified) return;
const mcJson = await fse.readJson(mcJsonPath);
if (mcJson.logging) {
// Verify logging xml
const { sha1: loggingHash, id: loggingId } = mcJson.logging.client.file;
if (loggingHash && loggingId) {
const loggingPath = path.join(
_getAssetsPath(state),
'objects',
loggingHash.substring(0, 2),
loggingId
);
verified = await verifyResource(loggingPath);
if (!verified) return;
}
}
// Verify assets
const assetsFile = path.join(
_getAssetsPath(state),
'indexes',
`${mcJson.assets}.json`
);
verified = await verifyResource(assetsFile);
if (!verified) return;
await fse.readJson(assetsFile);
const assetsJson = await fse.readJson(assetsFile);
const assets = Object.entries(assetsJson.objects).map(
([assetKey, { hash }]) => ({
url: `${MC_RESOURCES_URL}/${hash.substring(0, 2)}/${hash}`,
type: 'asset',
sha1: hash,
path: path.join(
_getAssetsPath(state),
'objects',
hash.substring(0, 2),
hash
),
resourcesPath: path.join(
_getInstancesPath(state),
instanceName,
'resources',
assetKey
),
legacyPath: path.join(
_getAssetsPath(state),
'virtual',
'legacy',
assetKey
)
})
);
let libraries = [];
let mcMainFile = {
url: mcJson.downloads.client.url,
sha1: mcJson.downloads.client.sha1,
path: path.join(_getMinecraftVersionsPath(state), `${mcJson.id}.jar`)
};
verified = await verifyResource(mcMainFile.path);
if (!verified) return;
if (loader && loader?.loaderType === 'fabric') {
const fabricJsonPath = path.join(
_getLibrariesPath(state),
'net',
'fabricmc',
loader?.mcVersion,
loader?.loaderVersion,
'fabric.json'
);
verified = await verifyResource(fabricJsonPath);
if (!verified) return;
const fabricJson = await fse.readJson(fabricJsonPath);
const fabricLibraries = librariesMapper(
fabricJson.libraries,
librariesPath
);
libraries = libraries.concat(fabricLibraries);
// Replace classname
mcJson.mainClass = fabricJson.mainClass;
} else if (loader && loader?.loaderType === 'forge') {
const forgeJsonPath = path.join(
_getLibrariesPath(state),
'net',
'minecraftforge',
loader?.loaderVersion,
`${loader?.loaderVersion}.json`
);
verified = await verifyResource(forgeJsonPath);
if (!verified) return;
if (gt(coerce(loader?.mcVersion), coerce('1.5.2'))) {
const getForgeLastVer = ver =>
Number.parseInt(ver.split('.')[ver.split('.').length - 1], 10);
if (
lt(coerce(loader?.loaderVersion.split('-')[1]), coerce('10.13.1')) &&
gte(coerce(loader?.loaderVersion.split('-')[1]), coerce('9.11.1')) &&
getForgeLastVer(loader?.loaderVersion) < 1217 &&
getForgeLastVer(loader?.loaderVersion) > 935
) {
const moveJavaLegacyFixerToInstance = async () => {
await fs.lstat(path.join(_getDataStorePath(state), '__JLF__.jar'));
await fse.copy(
path.join(_getDataStorePath(state), '__JLF__.jar'),
instanceJLFPath
);
};
try {
await moveJavaLegacyFixerToInstance();
} catch {
await dispatch(downloadJavaLegacyFixer(loader));
await moveJavaLegacyFixerToInstance();
}
}
const forgeJson = await fse.readJson(forgeJsonPath);
const forgeLibraries = librariesMapper(
forgeJson.version.libraries,
librariesPath
);
libraries = libraries.concat(forgeLibraries);
// Replace classname
mcJson.mainClass = forgeJson.version.mainClass;
if (forgeJson.version.minecraftArguments) {
mcJson.minecraftArguments = forgeJson.version.minecraftArguments;
} else if (forgeJson.version.arguments.game) {
// 1.17 check
if (forgeJson.version.arguments.jvm) {
mcJson.forge = { arguments: {} };
mcJson.forge.arguments.jvm = forgeJson.version.arguments.jvm.map(
arg => {
return replaceLibraryDirectory(
arg
.replace(/\${version_name}/g, mcJson.id)
.replace(
/=\${library_directory}/g,
`="${_getLibrariesPath(state)}"`
),
_getLibrariesPath(state)
).replace(
/\${classpath_separator}/g,
process.platform === 'win32' ? '";' : '":'
);
}
);
}
mcJson.arguments.game = mcJson.arguments.game.concat(
forgeJson.version.arguments.game
);
}
} else {
mcMainFile = {
path: path.join(
_getMinecraftVersionsPath(state),
`${loader?.loaderVersion}.jar`
)
};
}
}
libraries = removeDuplicates(
libraries.concat(librariesMapper(mcJson.libraries, librariesPath)),
'url'
);
for (const resource of [...libraries, ...assets]) {
try {
await fs.access(resource.path);
} catch {
console.warn(`Missing resource: ${resource.path}`);
missingResource = true;
}
}
if (missingResource) {
dispatch(
addToQueue(
instanceName,
instanceState.loader,
null,
instanceState.background
)
);
await new Promise(resolve => {
const unsubscribe = window.__store.subscribe(() => {
if (!getState().downloadQueue[instanceName]) {
unsubscribe();
return resolve();
}
});
});
dispatch(launchInstance(instanceName, true));
return;
}
ga.sendCustomEvent('launchedInstance');
dispatch(openModal('InstanceStartupAd', { instanceName }));
const getJvmArguments =
mcJson.assets !== 'legacy' && gte(coerce(mcJson.assets), coerce('1.13'))
? getJVMArguments113
: getJVMArguments112;
const javaArguments = (javaArgs !== undefined ? javaArgs : args).split(' ');
const javaMem = javaMemory !== undefined ? javaMemory : memory;
const gameResolution = instanceResolution || globalMinecraftResolution;
const jvmArguments = getJvmArguments(
libraries,
mcMainFile,
instancePath,
assetsPath,
mcJson,
account,
javaMem,
gameResolution,
false,
javaArguments
);
const { mcStartupMethod } = state.settings;
let replaceWith = `..${path.sep}..`;
const symLinkDirPath = path.join(userData.split('\\')[0], '_gdl');
if (MC_STARTUP_METHODS[mcStartupMethod] === MC_STARTUP_METHODS.SYMLINK) {
replaceWith = symLinkDirPath;
if (process.platform === 'win32') await symlink(userData, symLinkDirPath);
}
const replaceRegex = [
process.platform === 'win32'
? new RegExp(userData.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'), 'g')
: null,
replaceWith
];
const { sha1: loggingHash, id: loggingId } =
mcJson?.logging?.client?.file || {};
const loggingPath = path.join(
assetsPath,
'objects',
loggingHash?.substring(0, 2) || '',
loggingId || ''
);
const needsQuote = process.platform !== 'win32';
console.log(
`${addQuotes(needsQuote, javaPath)} ${getJvmArguments(
libraries,
mcMainFile,
instancePath,
assetsPath,
mcJson,
account,
javaMem,
gameResolution,
true,
javaArguments
).join(' ')}`
.replace(...replaceRegex)
.replace(
// eslint-disable-next-line no-template-curly-in-string
'-Dlog4j.configurationFile=${path}',
`-Dlog4j.configurationFile=${addQuotes(needsQuote, loggingPath)}`
)
);
if (state.settings.hideWindowOnGameLaunch) {
await ipcRenderer.invoke('hide-window');
}
let closed = false;
const ps = spawn(
`${addQuotes(needsQuote, javaPath)}`,
jvmArguments.map(v =>
v
.toString()
.replace(...replaceRegex)
.replace(
// eslint-disable-next-line no-template-curly-in-string
'-Dlog4j.configurationFile=${path}',
`-Dlog4j.configurationFile=${addQuotes(needsQuote, loggingPath)}`
)
),
{
cwd: instancePath,
shell: process.platform !== 'win32',
detached: true
}
);
const playTimer = setInterval(() => {
dispatch(
updateInstanceConfig(instanceName, prev => ({
...prev,
timePlayed: (Number(prev.timePlayed) || 0) + 1
}))
);
}, 60 * 1000);
dispatch(
updateInstanceConfig(instanceName, prev => ({
...prev,
lastPlayed: Date.now()
}))
);
dispatch(updateStartedInstance({ instanceName, pid: ps.pid }));
ps.stdout.on('data', data => {
console.log(data.toString());
if (
data.toString().includes('Setting user:') ||
data.toString().includes('Initializing LWJGL OpenAL')
) {
if (
!closed &&
getState().modals.find(v => v.modalType === 'InstanceStartupAd')
) {
closed = true;
dispatch(closeModal());
}
dispatch(updateStartedInstance({ instanceName, initialized: true }));
}
});
ps.stderr.on('data', data => {
console.error(`ps stderr: ${data}`);
errorLogs += data || '';
if (
data.toString().includes('Setting user:') ||
data.toString().includes('Initializing LWJGL OpenAL')
) {
if (
!closed &&
getState().modals.find(v => v.modalType === 'InstanceStartupAd')
) {
closed = true;
dispatch(closeModal());
}
dispatch(updateStartedInstance({ instanceName, initialized: true }));
}
});
ps.on('close', async code => {
clearInterval(playTimer);
if (!ps.killed) {
ps.kill('SIGKILL');
}
ipcRenderer.invoke('reset-discord-rpc');
await new Promise(resolve => setTimeout(resolve, 200));
ipcRenderer.invoke('show-window');
dispatch(removeStartedInstance(instanceName));
await fse.remove(instanceJLFPath);
await fs.unlink(backupConfigPath);
if (
process.platform === 'win32' &&
MC_STARTUP_METHODS[mcStartupMethod] === MC_STARTUP_METHODS.SYMLINK
) {
fse.remove(symLinkDirPath);
}
if (
!closed &&
getState().modals.find(v => v.modalType === 'InstanceStartupAd')
) {
dispatch(closeModal());
}
if (code !== 0 && errorLogs) {
setTimeout(() => {
dispatch(
openModal('InstanceCrashed', {
code,
errorLogs: errorLogs?.toString('utf8')
})
);
}, 225);
console.warn(`Process exited with code ${code}. Not too good..`);
}
});
};
}
export function installMod(
projectID,
fileID,
instanceName,
gameVersions,
installDeps = true,
onProgress,
useTempMiddleware,
item
) {
return async (dispatch, getState) => {
const state = getState();
const instancesPath = _getInstancesPath(state);
const instancePath = path.join(instancesPath, instanceName);
const instance = _getInstance(state)(instanceName);
const mainModData = await getAddonFile(projectID, fileID);
const addon = await getAddon(projectID);
mainModData.projectID = projectID;
const destFile = path.join(instancePath, 'mods', mainModData.fileName);
const tempFile = path.join(_getTempPath(state), mainModData.fileName);
const installedMods = [];
const removeModFromConfig = async () => {
await Promise.all(
installedMods.map(async mod => {
if (mod.needToAddMod) {
await dispatch(
updateInstanceConfig(instanceName, prev => {
return {
...prev,
mods: [...prev.mods.filter(m => m.modId !== mod.addon.id)]
};
})
);
}
})
);
};
const urlDownloadPage = `https://www.curseforge.com/minecraft/mc-mods/${item.slug}/download/${mainModData.id}`;
try {
if (useTempMiddleware) {
if (!mainModData.downloadUrl) {
try {
await browserDownload(urlDownloadPage, destFile);
} catch (e) {
await removeModFromConfig();
dispatch(
openModal('InfoModal', {
modName: mainModData.name,
preventClose: false,
error: e
})
);
}
} else {
await downloadFile(tempFile, mainModData.downloadUrl, onProgress);
}
}
let needToAddMod = true;
await dispatch(
updateInstanceConfig(instanceName, prev => {
needToAddMod = !prev.mods.find(
v => v.fileID === fileID && v.projectID === projectID
);
return {
...prev,
mods: [
...prev.mods,
...(needToAddMod
? [normalizeModData(mainModData, projectID, addon.name)]
: [])
]
};
})
);
installedMods.push({ addon, projectID, needToAddMod });
if (!needToAddMod) {
if (useTempMiddleware) {
await fse.remove(tempFile);
}
return;
}
if (!useTempMiddleware) {
try {
await fse.access(destFile);
const murmur2 = await getFileMurmurHash2(destFile);
if (murmur2 !== mainModData.fileFingerprint) {
if (!mainModData.downloadUrl) {
try {
await browserDownload(urlDownloadPage, destFile);
} catch (e) {
await removeModFromConfig();
dispatch(
openModal('InfoModal', {
modName: mainModData.name,
preventClose: false,
error: e
})
);
}
} else {
await downloadFile(destFile, mainModData.downloadUrl, onProgress);
}
}
} catch {
if (!mainModData.downloadUrl) {
try {
await browserDownload(urlDownloadPage, destFile);
} catch (e) {
await removeModFromConfig();
dispatch(
openModal('InfoModal', {
modName: mainModData.name,
preventClose: false,
error: e
})
);
}
} else {
await downloadFile(destFile, mainModData.downloadUrl, onProgress);
}
}
} else {
await fse.move(tempFile, destFile, { overwrite: true });
}
if (installDeps) {
await pMap(
mainModData.dependencies,
async dep => {
// type 1: embedded
// type 2: optional
// type 3: required
// type 4: tool
// type 5: incompatible
// type 6: include
if (dep.relationType === 3) {
if (instance.mods.some(x => x.addonId === dep.modId)) return;
const depList = await getAddonFiles(dep.modId);
const depData = depList.find(v =>
v.gameVersions.includes(gameVersions)
);
await dispatch(
installMod(
dep.modId,
depData.id,
instanceName,
gameVersions,
installDeps,
onProgress,
useTempMiddleware,
item
)
);
}
},
{ concurrency: 2 }
);
}
return destFile;
} catch (e) {
await removeModFromConfig();
}
};
}
export const deleteMod = (instanceName, mod) => {
return async (dispatch, getState) => {
const instancesPath = _getInstancesPath(getState());
await dispatch(
updateInstanceConfig(instanceName, prev => ({
...prev,
mods: prev.mods.filter(m => m.fileName !== mod.fileName)
}))
);
await fse.remove(
path.join(instancesPath, instanceName, 'mods', mod.fileName)
);
};
};
export const updateMod = (
instanceName,
mod,
fileID,
gameVersions,
onProgress
) => {
return async dispatch => {
const addon = await getAddon(mod.modId);
const item = { ...mod, slug: addon.slug };
await dispatch(
installMod(
mod.projectID,
fileID,
instanceName,
gameVersions,
false,
onProgress,
true,
item
)
);
await dispatch(deleteMod(instanceName, mod));
};
};
export const initLatestMods = instanceName => {
return async (dispatch, getState) => {
const state = getState();
const instance = _getInstance(state)(instanceName);
const { latestModManifests } = state;
const modIds = instance?.mods
?.filter(v => v.projectID)
?.map(v => v.projectID);
// Check which mods need to be initialized
const modsToInit = modIds?.filter(v => {
return !latestModManifests[v];
});
if (!modsToInit || modsToInit?.length === 0) return;
// Need to split in multiple requests
const manifests = await pMap(
modsToInit,
async mod => {
let data = null;
try {
data = await getAddonFiles(mod);
} catch {
// nothing
}
return { projectID: mod, data };
},
{ concurrency: 40 }
);
const manifestsObj = {};
manifests
.filter(v => v.data)
.map(v => {
// Find latest version for each mod
const [latestMod] =
getPatchedInstanceType(instance) === FORGE || v.projectID === 361988
? filterForgeFilesByVersion(v.data, instance.loader?.mcVersion)
: filterFabricFilesByVersion(v.data, instance.loader?.mcVersion);
if (latestMod) {
manifestsObj[v.projectID] = latestMod;
}
return null;
});
dispatch(updateLatestModManifests(manifestsObj));
};
};
export const isNewVersionAvailable = async () => {
const { data: latestReleases } = await axios.get(
'https://api.github.com/repos/gorilla-devs/GDLauncher/releases?per_page=10'
);
const latestPrerelease = latestReleases.find(v => v.prerelease);
const latestStablerelease = latestReleases.find(v => !v.prerelease);
const appData = await ipcRenderer.invoke('getAppdataPath');
let releaseChannel = 0;
try {
const rChannel = await fs.readFile(
path.join(appData, 'gdlauncher_next', 'rChannel')
);
releaseChannel = parseInt(rChannel.toString(), 10);
} catch {
// swallow error
}
const v = await ipcRenderer.invoke('getAppVersion');
const isAppUpdated = r => gte(v, r.tag_name);
if (
releaseChannel === 0 &&
(prerelease(v) || !isAppUpdated(latestStablerelease))
) {
return latestStablerelease;
}
if (releaseChannel !== 0) {
if (
!isAppUpdated(latestStablerelease) &&
(major(latestStablerelease.tag_name) > major(v) ||
minor(latestStablerelease.tag_name) > minor(v) ||
patch(latestStablerelease.tag_name) > patch(v))
) {
return latestStablerelease;
}
if (latestPrerelease && !isAppUpdated(latestPrerelease)) {
return latestPrerelease;
}
}
return false;
};
export const checkForPortableUpdates = () => {
return async (dispatch, getState) => {
const state = getState();
const baseFolder = await ipcRenderer.invoke('getExecutablePath');
const tempFolder = path.join(_getTempPath(state), `update`);
const newVersion = await isNewVersionAvailable();
// Latest version has a value only if the user is not using the latest
if (newVersion) {
const baseAssetUrl = `https://github.com/gorilla-devs/GDLauncher/releases/download/${newVersion?.tag_name}`;
const { data: latestManifest } = await axios.get(
`${baseAssetUrl}/${process.platform}_latest.json`
);
// Cleanup all files that are not required for the update
await makeDir(tempFolder);
const filesToUpdate = (
await Promise.all(
latestManifest.map(async file => {
const fileOnDisk = path.join(baseFolder, ...file.file);
let needsDownload = false;
try {
// Check if files exists
await originalFs.promises.stat(fileOnDisk);
const fileOnDiskSha1 = await getFileHash(fileOnDisk);
if (fileOnDiskSha1.toString() !== file.sha1) {
throw new Error('SHA1 Mismatch', file.compressedFile);
}
} catch (err) {
needsDownload = true;
}
if (needsDownload) {
return file;
}
return null;
})
)
).filter(_ => _);
const tempFiles = await getFilesRecursive(tempFolder);
await Promise.all(
tempFiles.map(async tempFile => {
const tempFileRelativePath = path.relative(tempFolder, tempFile);
const isNeeded = filesToUpdate.find(
v => path.join(...v.file) === tempFileRelativePath
);
if (!isNeeded) {
await fse.remove(tempFile);
}
})
);
await pMap(
filesToUpdate,
async file => {
const compressedFile = path.join(tempFolder, file.compressedFile);
const destinationPath = path.join(tempFolder, ...file.file);
try {
// Check if files exists
await originalFs.promises.access(destinationPath);
const fileSha1 = await getFileHash(destinationPath);
if (fileSha1.toString() !== file.sha1) {
throw new Error('SHA1 mismatch', file.compressedFile);
}
} catch {
try {
try {
await originalFs.promises.access(compressedFile);
const fileSha1 = await getFileHash(compressedFile);
if (fileSha1.toString() === file.sha1) {
return;
}
} catch {
// Nothing, just go ahead and download since sha1 mismatch
}
// Try to download 5 times
const maxTries = 5;
let sha1Matched = false;
while (maxTries <= 5 && !sha1Matched) {
// eslint-disable-next-line
await downloadFile(
compressedFile,
`${baseAssetUrl}/${file.compressedFile}`
);
// eslint-disable-next-line
const fileSha1 = await getFileHash(compressedFile);
if (fileSha1.toString() === file.compressedSha1) {
sha1Matched = true;
}
}
if (!sha1Matched) {
throw new Error(`Could not download ${file.compressedSha1}`);
}
const gzip = zlib.createGunzip();
const source = originalFs.createReadStream(compressedFile);
await makeDir(path.dirname(destinationPath));
const destination = originalFs.createWriteStream(destinationPath);
await new Promise((resolve, reject) => {
pipeline(source, gzip, destination, err => {
if (err) {
reject(err);
}
destination.close();
resolve();
});
});
await fse.remove(compressedFile);
} catch (err) {
throw new Error(err);
}
}
},
{ concurrency: 3 }
);
}
return newVersion;
};
};