2a134f0b09
This reverts commit 018442e94f.
3485 lines
99 KiB
JavaScript
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;
|
|
};
|
|
};
|