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; }; };