From 97b4b060bcab40e631ee0b74f726aa7826d15ea2 Mon Sep 17 00:00:00 2001 From: Pyroglyph Date: Mon, 4 Jul 2022 23:07:33 +0100 Subject: [PATCH 1/8] Add Modrinth mod/modpack support Squashed from Pyroglyph/GDLauncher/tree/modrinth-support --- jsconfig.json | 3 + src/app/desktop/components/Instances/index.js | 1 - src/app/desktop/utils/index.js | 79 ++- src/app/desktop/utils/withScroll.js | 6 +- src/common/api.js | 269 ++++++- src/common/assets/modrinthIcon.webp | Bin 0 -> 26970 bytes src/common/modals/AddInstance/Content.js | 18 + .../AddInstance/CurseForgeModpacks/index.js | 4 +- src/common/modals/AddInstance/InstanceName.js | 100 ++- .../ModrinthModpacks/ModpacksListWrapper.js | 266 +++++++ .../AddInstance/ModrinthModpacks/index.js | 174 +++++ src/common/modals/CurseForgeModsBrowser.js | 597 ++++++++++++++++ src/common/modals/InstanceDownloadFailed.js | 19 +- src/common/modals/InstanceManager/Modpack.js | 132 +++- src/common/modals/InstanceManager/Mods.js | 56 +- src/common/modals/InstanceManager/Overview.js | 4 +- src/common/modals/InstanceManager/index.js | 1 + src/common/modals/ModChangelog.js | 45 +- src/common/modals/ModOverview.js | 181 +++-- src/common/modals/ModpackDescription.js | 163 +++-- src/common/modals/ModrinthModsBrowser.js | 589 +++++++++++++++ src/common/modals/ModsBrowser.js | 629 ++--------------- src/common/reducers/actionTypes.js | 2 + src/common/reducers/actions.js | 668 +++++++++++++----- src/common/reducers/app.js | 12 +- src/common/utils/constants.js | 2 + src/common/utils/index.js | 19 - src/types/modrinth.js | 147 ++++ 28 files changed, 3187 insertions(+), 999 deletions(-) create mode 100644 jsconfig.json create mode 100644 src/common/assets/modrinthIcon.webp create mode 100644 src/common/modals/AddInstance/ModrinthModpacks/ModpacksListWrapper.js create mode 100644 src/common/modals/AddInstance/ModrinthModpacks/index.js create mode 100644 src/common/modals/CurseForgeModsBrowser.js create mode 100644 src/common/modals/ModrinthModsBrowser.js create mode 100644 src/types/modrinth.js diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 000000000..51a19db1b --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,3 @@ +{ + "include": ["src/**/*.js"] +} diff --git a/src/app/desktop/components/Instances/index.js b/src/app/desktop/components/Instances/index.js index e1503d14d..7aebe0e91 100644 --- a/src/app/desktop/components/Instances/index.js +++ b/src/app/desktop/components/Instances/index.js @@ -8,7 +8,6 @@ const Container = styled.div` display: flex; flex-wrap: wrap; width: 100%; - justify-content: center; margin-bottom: 2rem; `; diff --git a/src/app/desktop/utils/index.js b/src/app/desktop/utils/index.js index cc989db9e..558d80752 100644 --- a/src/app/desktop/utils/index.js +++ b/src/app/desktop/utils/index.js @@ -1,7 +1,7 @@ import { promises as fs } from 'fs'; import originalFs from 'original-fs'; import fse from 'fs-extra'; -import { extractFull } from 'node-7z'; +import * as Seven from 'node-7z'; import jimp from 'jimp/es'; import makeDir from 'make-dir'; import { promisify } from 'util'; @@ -17,10 +17,10 @@ import { } from '../../../common/utils/constants'; import { - addQuotes, removeDuplicates, sortByForgeVersionDesc } from '../../../common/utils'; +// eslint-disable-next-line import/no-cycle import { getAddon, getAddonFile, mcGetPlayerSkin } from '../../../common/api'; import { downloadFile } from './downloader'; import browserDownload from '../../../common/utils/browserDownload'; @@ -395,6 +395,38 @@ export const get7zPath = async () => { get7zPath(); +export const extract = async (source, destination, args = {}, funcs = {}) => { + const sevenZipPath = await get7zPath(); + const extraction = Seven.extract(source, destination, { + ...args, + yes: true, + $bin: sevenZipPath, + $spawnOptions: { shell: true } + }); + let extractedParentDir = null; + await new Promise((resolve, reject) => { + if (funcs.progress) { + extraction.on('progress', ({ percent }) => { + funcs.progress(percent); + }); + } + extraction.on('data', data => { + if (!extractedParentDir) { + [extractedParentDir] = data.file.split('/'); + } + }); + extraction.on('end', () => { + funcs.end?.(); + resolve(extractedParentDir); + }); + extraction.on('error', err => { + funcs.error?.(); + reject(err); + }); + }); + return { extraction, extractedParentDir }; +}; + export const extractAll = async ( source, destination, @@ -402,7 +434,7 @@ export const extractAll = async ( funcs = {} ) => { const sevenZipPath = await get7zPath(); - const extraction = extractFull(source, destination, { + const extraction = Seven.extractFull(source, destination, { ...args, yes: true, $bin: sevenZipPath, @@ -484,14 +516,13 @@ export const getJVMArguments112 = ( hideAccessToken, jvmOptions = [] ) => { - const needsQuote = process.platform !== 'win32'; const args = []; args.push('-cp'); args.push( [...libraries, mcjar] .filter(l => !l.natives) - .map(l => `${addQuotes(needsQuote, l.path)}`) + .map(l => `"${l.path}"`) .join(process.platform === 'win32' ? ';' : ':') ); @@ -503,15 +534,8 @@ export const getJVMArguments112 = ( args.push(`-Xmx${memory}m`); args.push(`-Xms${memory}m`); args.push(...jvmOptions); - args.push( - `-Djava.library.path=${addQuotes( - needsQuote, - path.join(instancePath, 'natives') - )}` - ); - args.push( - `-Dminecraft.applet.TargetDirectory=${addQuotes(needsQuote, instancePath)}` - ); + args.push(`-Djava.library.path="${path.join(instancePath, 'natives')}"`); + args.push(`-Dminecraft.applet.TargetDirectory="${instancePath}"`); if (mcJson.logging) { args.push(mcJson?.logging?.client?.argument || ''); } @@ -533,13 +557,13 @@ export const getJVMArguments112 = ( val = mcJson.id; break; case 'game_directory': - val = `${addQuotes(needsQuote, instancePath)}`; + val = `"${instancePath}"`; break; case 'assets_root': - val = `${addQuotes(needsQuote, assetsPath)}`; + val = `"${assetsPath}"`; break; case 'game_assets': - val = `${path.join(assetsPath, 'virtual', 'legacy')}`; + val = `"${path.join(assetsPath, 'virtual', 'legacy')}"`; break; case 'assets_index_name': val = mcJson.assets; @@ -568,9 +592,6 @@ export const getJVMArguments112 = ( if (val != null) { mcArgs[i] = val; } - if (typeof args[i] === 'string' && !needsQuote) { - args[i] = args[i].replaceAll('"', ''); - } } } @@ -598,7 +619,6 @@ export const getJVMArguments113 = ( ) => { const argDiscovery = /\${*(.*)}/; let args = mcJson.arguments.jvm.filter(v => !skipLibrary(v)); - const needsQuote = process.platform !== 'win32'; // if (process.platform === "darwin") { // args.push("-Xdock:name=instancename"); @@ -607,9 +627,7 @@ export const getJVMArguments113 = ( args.push(`-Xmx${memory}m`); args.push(`-Xms${memory}m`); - args.push( - `-Dminecraft.applet.TargetDirectory=${addQuotes(needsQuote, instancePath)}` - ); + args.push(`-Dminecraft.applet.TargetDirectory="${instancePath}"`); if (mcJson.logging) { args.push(mcJson?.logging?.client?.argument || ''); } @@ -627,9 +645,9 @@ export const getJVMArguments113 = ( for (let i = 0; i < args.length; i += 1) { if (typeof args[i] === 'object' && args[i].rules) { if (typeof args[i].value === 'string') { - args[i] = `${addQuotes(needsQuote, args[i].value)}`; + args[i] = `"${args[i].value}"`; } else if (typeof args[i].value === 'object') { - args.splice(i, 1, ...args[i].value.map(v => `${v}`)); + args.splice(i, 1, ...args[i].value.map(v => `"${v}"`)); } i -= 1; } else if (typeof args[i] === 'string') { @@ -644,10 +662,10 @@ export const getJVMArguments113 = ( val = mcJson.id; break; case 'game_directory': - val = `${addQuotes(needsQuote, instancePath)}`; + val = `"${instancePath}"`; break; case 'assets_root': - val = `${addQuotes(needsQuote, assetsPath)}`; + val = `"${assetsPath}"`; break; case 'assets_index_name': val = mcJson.assets; @@ -673,7 +691,7 @@ export const getJVMArguments113 = ( case 'natives_directory': val = args[i].replace( argDiscovery, - `${addQuotes(needsQuote, path.join(instancePath, 'natives'))}` + `"${path.join(instancePath, 'natives')}"` ); break; case 'launcher_name': @@ -685,7 +703,7 @@ export const getJVMArguments113 = ( case 'classpath': val = [...libraries, mcjar] .filter(l => !l.natives) - .map(l => `${addQuotes(needsQuote, l.path)}`) + .map(l => `"${l.path}"`) .join(process.platform === 'win32' ? ';' : ':'); break; default: @@ -695,7 +713,6 @@ export const getJVMArguments113 = ( args[i] = val; } } - if (!needsQuote) args[i] = args[i].replaceAll('"', ''); } } diff --git a/src/app/desktop/utils/withScroll.js b/src/app/desktop/utils/withScroll.js index 80b57f4aa..5ee52e61a 100644 --- a/src/app/desktop/utils/withScroll.js +++ b/src/app/desktop/utils/withScroll.js @@ -17,7 +17,11 @@ const withScroll = Component => { justify-content: center; `} > -
+
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
diff --git a/src/common/api.js b/src/common/api.js index a53cd955d..f57564e5f 100644 --- a/src/common/api.js +++ b/src/common/api.js @@ -1,6 +1,9 @@ // @flow import axios from 'axios'; import qs from 'querystring'; +import path from 'path'; +import fse from 'fs-extra'; +import os from 'os'; import { MOJANG_APIS, FORGESVC_URL, @@ -13,10 +16,14 @@ import { MICROSOFT_XSTS_AUTH_URL, MINECRAFT_SERVICES_URL, FTB_API_URL, + MODRINTH_API_URL, JAVA_LATEST_MANIFEST_URL } from './utils/constants'; import { sortByDate } from './utils'; import ga from './utils/analytics'; +import { downloadFile } from '../app/desktop/utils/downloader'; +// eslint-disable-next-line import/no-cycle +import { extractAll } from '../app/desktop/utils'; const axioInstance = axios.create({ headers: { @@ -30,6 +37,10 @@ const trackFTBAPI = () => { ga.sendCustomEvent('FTBAPICall'); }; +const trackModrinthAPI = () => { + ga.sendCustomEvent('ModrinthAPICall'); +}; + const trackCurseForgeAPI = () => { ga.sendCustomEvent('CurseForgeAPICall'); }; @@ -298,7 +309,7 @@ export const getAddonFileChangelog = async (projectID, fileID) => { return data?.data; }; -export const getAddonCategories = async () => { +export const getCurseForgeCategories = async () => { trackCurseForgeAPI(); const url = `${FORGESVC_URL}/categories?gameId=432`; const { data } = await axioInstance.get(url); @@ -312,7 +323,7 @@ export const getCFVersionIds = async () => { return data.data; }; -export const getSearch = async ( +export const getCurseForgeSearch = async ( type, searchFilter, pageSize, @@ -410,3 +421,257 @@ export const getFTBSearch = async searchText => { const url = `${FTB_API_URL}/modpack/search/1000?term=${searchText}`; return axios.get(url); }; + +/** + * @param {number} offset + * @returns {Promise} + */ +export const getModrinthMostPlayedModpacks = async (offset = 0) => { + trackModrinthAPI(); + const url = `${MODRINTH_API_URL}/search?limit=20&offset=${offset}&index=downloads&facets=[["project_type:modpack"]]`; + const { data } = await axios.get(url); + return data; +}; + +/** + * @param {string} query + * @param {'mod'|'modpack'} projectType + * @param {string} gameVersion + * @param {string[]} categories + * @param {number} index + * @param {number} offset + * @returns {Promise} + */ +export const getModrinthSearchResults = async ( + query, + projectType, + gameVersion = null, + categories = [], + index = 'relevance', + offset = 0 +) => { + trackModrinthAPI(); + const facets = []; + + if (projectType === 'MOD') { + facets.push(['project_type:mod']); + } + if (projectType === 'MODPACK') { + facets.push(['project_type:modpack']); + } + if (gameVersion) { + facets.push([`versions:${gameVersion}`]); + } + // remove falsy values (i.e. null/undefined) from categories before constructing facets + const filteredCategories = categories.filter(cat => !!cat); + if (filteredCategories) { + facets.push(...filteredCategories.map(cat => [`categories:${cat}`])); + } + + const { data } = await axios.get(`${MODRINTH_API_URL}/search`, { + params: { + limit: 20, + query: query ?? undefined, + facets: facets ? JSON.stringify(facets) : undefined, + index: index ?? undefined, + offset: offset ?? undefined + } + }); + + return data; +}; + +/** + * @param {string} projectId + * @returns {Promise} + */ +export const getModrinthProject = async projectId => { + return (await getModrinthProjects([projectId])).at(0) ?? null; +}; + +/** + * @param {string[]} projectIds + * @returns {Promise} + */ +export const getModrinthProjects = async projectIds => { + trackModrinthAPI(); + try { + const url = `${MODRINTH_API_URL}/projects?ids=${JSON.stringify( + projectIds + )}`; + const { data } = await axios.get(url); + return data.map(fixModrinthProjectObject); + } catch { + return { status: 'error' }; + } +}; + +/** + * @param {string} projectId + * @returns {Promise} + */ +export const getModrinthProjectVersions = async projectId => { + trackModrinthAPI(); + try { + const url = `${MODRINTH_API_URL}/project/${projectId}/version`; + const { data } = await axios.get(url); + return data; + } catch { + return { status: 'error' }; + } +}; + +/** + * @param {string} versionId + * @returns {Promise} + */ +export const getModrinthVersion = async versionId => { + return (await getModrinthVersions([versionId])).at(0) ?? null; +}; + +/** + * @param {string[]} versionIds + * @returns {Promise} + */ +export const getModrinthVersions = async versionIds => { + trackModrinthAPI(); + try { + const url = `${MODRINTH_API_URL}/versions?ids=${JSON.stringify( + versionIds + )}`; + const { data } = await axios.get(url); + return data || []; + } catch (err) { + console.error(err); + } +}; + +// TODO: Move override logic out of this function +// TODO: Do overrides need to be applied after the pack is installed? +/** + * @param {string} versionId + * @param {string} instancePath + * @returns {Promise} + */ +export const getModrinthVersionManifest = async (versionId, instancePath) => { + try { + // get download link for the metadata archive + const version = await getModrinthVersion(versionId); + const file = version.files.find(f => f.filename.endsWith('.mrpack')); + + // clean temp directory + const tmp = path.join(os.tmpdir(), 'GDLauncher_Download'); + await fse.rm(tmp, { recursive: true, force: true }); + + // download metadata archive + await downloadFile(path.join(tmp, file.filename), file.url); + + // Wait 500ms to avoid `The process cannot access the file because it is being used by another process.` + await new Promise(resolve => { + setTimeout(() => resolve(), 500); + }); + + // extract archive to temp folder + await extractAll(path.join(tmp, file.filename), tmp, { yes: true }); + + await fse.move(path.join(tmp, 'overrides'), path.join(instancePath), { + overwrite: true + }); + + // move manifest to instance root + await fse.move( + path.join(tmp, 'modrinth.index.json'), + path.join(instancePath, 'modrinth.index.json'), + { overwrite: true } + ); + + // clean temp directory + await fse.rm(tmp, { recursive: true, force: true }); + + const manifest = await fse.readJson( + path.join(instancePath, 'modrinth.index.json') + ); + + return manifest; + } catch (err) { + console.error(err); + + return { status: 'error' }; + } +}; + +/** + * @param {string} versionId + * @returns {Promise} + */ +export const getModrinthVersionChangelog = async versionId => { + return (await getModrinthVersion(versionId)).changelog; +}; + +/** + * @param {string} userId + * @returns {Promise} + */ +export const getModrinthUser = async userId => { + trackModrinthAPI(); + try { + const url = `${MODRINTH_API_URL}/user/${userId}`; + const { data } = await axios.get(url); + return data; + } catch (err) { + console.error(err); + } +}; + +//! HACK +const fixModrinthProjectObject = project => { + return { + ...project, + name: project.title + }; +}; + +/** + * @returns {Promise} + */ +export const getModrinthCategories = async () => { + trackModrinthAPI(); + try { + const url = `${MODRINTH_API_URL}/tag/category`; + const { data } = await axios.get(url); + return data; + } catch (err) { + console.error(err); + } +}; + +/** + * @param {string} projectId + * @returns {Promise} + */ +export const getModrinthProjectMembers = async projectId => { + trackModrinthAPI(); + try { + const url = `${MODRINTH_API_URL}/project/${projectId}/members`; + const { data } = await axios.get(url); + return data; + } catch (err) { + console.error(err); + } +}; + +/** + * @param {string[]} hashes + * @param {'sha1' | 'sha512'} algorithm + * @returns {Promise<{[hash: string]: ModrinthVersion}[]>} + */ +export const getVersionsFromHashes = async (hashes, algorithm) => { + trackModrinthAPI(); + try { + const url = `${MODRINTH_API_URL}/version_files`; + const { data } = await axios.post(url, { hashes, algorithm }); + return data; + } catch (err) { + console.error(err); + } +}; diff --git a/src/common/assets/modrinthIcon.webp b/src/common/assets/modrinthIcon.webp new file mode 100644 index 0000000000000000000000000000000000000000..abab8f33cf3ccbbe10b54908a5ba6a4db827b62c GIT binary patch literal 26970 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 literal 0 HcmV?d00001 diff --git a/src/common/modals/AddInstance/Content.js b/src/common/modals/AddInstance/Content.js index 8cee7e658..2fd0a21f5 100644 --- a/src/common/modals/AddInstance/Content.js +++ b/src/common/modals/AddInstance/Content.js @@ -16,6 +16,8 @@ import NewInstance from './NewInstance'; import minecraftIcon from '../../assets/minecraftIcon.png'; import curseForgeIcon from '../../assets/curseforgeIcon.webp'; import ftbIcon from '../../assets/ftbIcon.webp'; +import modrinthIcon from '../../assets/modrinthIcon.webp'; +import ModrinthModpacks from './ModrinthModpacks'; const Content = ({ in: inProp, @@ -49,6 +51,11 @@ const Content = ({ setVersion={setVersion} setStep={setStep} setModpack={setModpack} + />, + ]; @@ -116,6 +123,17 @@ const Content = ({ /> FTB + + + Modrinth + { if (error) { setError(false); } - data = await getSearch( + data = await getCurseForgeSearch( 'modpacks', searchText, 40, diff --git a/src/common/modals/AddInstance/InstanceName.js b/src/common/modals/AddInstance/InstanceName.js index 9dc61417b..4adb46570 100644 --- a/src/common/modals/AddInstance/InstanceName.js +++ b/src/common/modals/AddInstance/InstanceName.js @@ -24,8 +24,20 @@ import { import { _getInstancesPath, _getTempPath } from '../../utils/selectors'; import bgImage from '../../assets/mcCube.jpg'; import { downloadFile } from '../../../app/desktop/utils/downloader'; -import { FABRIC, VANILLA, FORGE, FTB, CURSEFORGE } from '../../utils/constants'; -import { getFTBModpackVersionData } from '../../api'; +import { + FABRIC, + VANILLA, + FORGE, + FTB, + CURSEFORGE, + MODRINTH +} from '../../utils/constants'; +import { + getFTBModpackVersionData, + getModrinthVersion, + getModrinthVersionManifest, + getModrinthVersions +} from '../../api'; const InstanceName = ({ in: inProp, @@ -77,16 +89,25 @@ const InstanceName = ({ const imageURL = useMemo(() => { if (!modpack) return null; - // Curseforge - if (!modpack.synopsis) { - return modpack?.logo?.thumbnailUrl; - } else { + + if (modpack.art) { // FTB const image = modpack?.art?.reduce((prev, curr) => { if (!prev || curr.size < prev.size) return curr; return prev; }); return image.url; + } else if (modpack.gallery) { + // Modrinth + return ( + modpack.gallery?.find(img => img.featured)?.url || + modpack.gallery?.at(0)?.url || + modpack.icon_url || + '' + ); + } else { + // Curseforge + return modpack?.logo?.thumbnailUrl; } }, [modpack]); @@ -103,6 +124,7 @@ const InstanceName = ({ const isCurseForgeModpack = Boolean(version?.source === CURSEFORGE); const isFTBModpack = Boolean(modpack?.art); + const isModrinthModpack = Boolean(modpack?.project_type); let manifest; // If it's a curseforge modpack grab the manfiest and detect the loader @@ -288,6 +310,72 @@ const InstanceName = ({ ramAmount ? { javaMemory: ramAmount } : null ) ); + } else if (isModrinthModpack) { + //! manifest.dependencies and fullVersion.dependencies are different things! + // manifest.dependencies contains only the game version and loader version (referred to here as mainDependencies) + // fullVersion.dependencies contains objects with mod ids + + const fullVersion = await getModrinthVersion(version?.fileID); + + const manifest = await getModrinthVersionManifest( + version?.fileID, + path.join(instancesPath, localInstanceName) + ); + + const mcVersion = manifest.dependencies.minecraft; + const mainDependencies = Object.keys(manifest.dependencies); + let loaderType; + let loaderVersion; + if (mainDependencies.includes('fabric-loader')) { + loaderType = FABRIC; + loaderVersion = manifest.dependencies['fabric-loader']; + } else if (mainDependencies.includes('forge')) { + loaderType = FORGE; + loaderVersion = convertcurseForgeToCanonical( + manifest.dependencies['forge'], + mcVersion, + forgeManifest + ); + } else if (mainDependencies.includes('quilt-loader')) { + // we don't support Quilt yet, so we can't proceed with the installation + dispatch(closeModal()); + throw 'Quilt modpacks are not yet supported.'; + + // loaderType = QUILT; + // loaderVersion = manifest.dependencies['quilt-loader']; + } + + const loader = { + loaderType, + mcVersion, + loaderVersion, + fileID: version?.fileID, + projectID: version?.projectID, + source: MODRINTH, + sourceName: originalMcName + }; + + if (imageURL) { + await downloadFile( + path.join( + instancesPath, + localInstanceName, + `background${path.extname(imageURL)}` + ), + imageURL + ); + } + + dispatch( + addToQueue( + localInstanceName, + loader, + manifest, + `background${path.extname(imageURL)}`, + null, + null + ) + ); } else if (importZipPath) { manifest = await importAddonZip( importZipPath, diff --git a/src/common/modals/AddInstance/ModrinthModpacks/ModpacksListWrapper.js b/src/common/modals/AddInstance/ModrinthModpacks/ModpacksListWrapper.js new file mode 100644 index 000000000..bc9649f96 --- /dev/null +++ b/src/common/modals/AddInstance/ModrinthModpacks/ModpacksListWrapper.js @@ -0,0 +1,266 @@ +import React, { forwardRef, memo, useContext, useEffect } from 'react'; +import styled, { ThemeContext } from 'styled-components'; +import { useDispatch } from 'react-redux'; +import { FixedSizeList as List } from 'react-window'; +import InfiniteLoader from 'react-window-infinite-loader'; +import ContentLoader from 'react-content-loader'; +import { transparentize } from 'polished'; +import { openModal } from '../../../reducers/modals/actions'; +import { MODRINTH } from '../../../utils/constants'; +import { getModrinthProject, getModrinthProjectVersions } from '../../../api'; + +const selectModrinthModpack = async ( + projectID, + setVersion, + setModpack, + setStep +) => { + // with a bit more fiddling the `getModrinthProject` call can be removed + const modpack = await getModrinthProject(projectID); + const version = (await getModrinthProjectVersions(projectID)).sort( + (a, b) => Date.parse(b.date_published) - Date.parse(a.date_published) + )[0]; + + // modpack versions should only ever have 1 loader and 1 minecraft version + // if this is not the case, the pack was configured incorrectly and would not have worked anyway + const loaderType = version.loaders[0]; + const mcVersion = version.game_versions[0]; + + setVersion({ + loaderType, + mcVersion, + projectID, + fileID: version.id, + source: MODRINTH + }); + setModpack(modpack); + setStep(1); +}; + +const ModpacksListWrapper = ({ + // Are there more items to load? + // (This information comes from the most recent API request.) + hasNextPage, + + // Are we currently loading a page of items? + // (This may be an in-flight flag in your Redux store for example.) + isNextPageLoading, + + // Array of items loaded so far. + items, + + height, + + width, + + setStep, + + setModpack, + + setVersion, + // Callback function responsible for loading the next page of items. + loadNextPage, + + infiniteLoaderRef +}) => { + const dispatch = useDispatch(); + // If there are more items to be loaded then add an extra row to hold a loading indicator. + const itemCount = hasNextPage ? items.length + 1 : items.length; + // Only load 1 page of items at a time. + // Pass an empty callback to InfiniteLoader in case it asks us to load more than once. + const loadMoreItems = isNextPageLoading ? () => {} : loadNextPage; + // Every row is loaded except for our loading indicator row. + const isItemLoaded = index => !hasNextPage || index < items.length; + + // Render an item or a loading indicator. + const Item = memo(({ index, style }) => { + const modpack = items[index]; + if (!modpack) { + return ( + + ); + } + + const primaryImage = + modpack.gallery?.find(img => img.featured)?.url || + modpack.gallery?.at(0)?.url || + modpack.icon_url || + ''; + + return ( +
+ +
{modpack.title}
+
+ +
{ + selectModrinthModpack( + modpack.project_id, + setVersion, + setModpack, + setStep + ); + }} + > + Download Latest +
+
{ + const realModpack = await getModrinthProject(modpack.project_id); + dispatch( + openModal('ModpackDescription', { + modpack: realModpack, + setStep, + setVersion, + setModpack, + type: MODRINTH + }) + ); + }} + > + Explore / Versions +
+
+
+ ); + }); + + const innerElementType = forwardRef(({ style, ...rest }, ref) => ( +
+ )); + + return ( + loadMoreItems()} + > + {({ onItemsRendered }) => ( + { + // Manually bind ref to reset scroll + // eslint-disable-next-line + infiniteLoaderRef.current = list; + }} + > + {Item} + + )} + + ); +}; + +export default memo(ModpacksListWrapper); + +const Modpack = styled.div` + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 20px; + padding: 0 10px; + font-weight: 700; + background: ${props => transparentize(0.2, props.theme.palette.grey[700])}; +`; + +const ModpackHover = styled.div` + position: absolute; + display: flex; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: ${props => transparentize(0.4, props.theme.palette.grey[900])}; + opacity: 0; + padding-left: 40%; + will-change: opacity; + transition: opacity 0.1s ease-in-out, background 0.1s ease-in-out; + div { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + background-color: transparent; + border-radius: 4px; + transition: background-color 0.1s ease-in-out; + &:hover { + background-color: ${props => props.theme.palette.primary.main}; + } + } + &:hover { + opacity: 1; + } +`; + +const ModpackLoader = memo( + ({ width, top, height, isNextPageLoading, hasNextPage, loadNextPage }) => { + const ContextTheme = useContext(ThemeContext); + + useEffect(() => { + if (hasNextPage && isNextPageLoading) { + loadNextPage(); + } + }, []); + return ( + + + + ); + } +); diff --git a/src/common/modals/AddInstance/ModrinthModpacks/index.js b/src/common/modals/AddInstance/ModrinthModpacks/index.js new file mode 100644 index 000000000..e87295e27 --- /dev/null +++ b/src/common/modals/AddInstance/ModrinthModpacks/index.js @@ -0,0 +1,174 @@ +/* eslint-disable */ +import React, { useState, useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { Input } from 'antd'; +import { useDebouncedCallback } from 'use-debounce'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import ModpacksListWrapper from './ModpacksListWrapper'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faBomb, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import { + getModrinthMostPlayedModpacks, + getModrinthSearchResults +} from '../../../api'; + +const ModrinthModpacks = ({ setStep, setModpack, setVersion }) => { + const infiniteLoaderRef = useRef(null); + const [modpacks, setModpacks] = useState([]); + const [loading, setLoading] = useState(true); + const [searchText, setSearchText] = useState(''); + const [hasNextPage, setHasNextPage] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + const init = async () => { + updateModpacks(); + }; + init(); + }, [searchText]); + + const updateModpacks = useDebouncedCallback(() => { + if (infiniteLoaderRef?.current?.scrollToItem) { + infiniteLoaderRef.current.scrollToItem(0); + } + loadMoreModpacks(true); + }, 250); + + const loadMoreModpacks = async (reset = false) => { + const searchResult = + searchText.length < 3 + ? await getModrinthMostPlayedModpacks() + : await getModrinthSearchResults(searchText, 'MODPACK'); + + if (!searchResult || modpacks.length == searchResult.total_hits) return; + + setLoading(true); + + if (reset) { + setModpacks([]); + setHasNextPage(false); + } + let data = null; + try { + setError(false); + + const offset = reset ? 0 : modpacks.length || 0; + data = + searchText.length < 3 + ? await getModrinthMostPlayedModpacks(offset) + : await getModrinthSearchResults( + searchText, + 'MODPACK', + null, + [], + null, + offset + ); + } catch (err) { + setError(err); + return; + } + + const newModpacks = reset ? data.hits : [...modpacks, ...data.hits]; + + setHasNextPage(newModpacks.length < searchResult.total_hits); + setModpacks(newModpacks); + + setLoading(false); + }; + + return ( + + + setSearchText(e.target.value)} + style={{ width: 200 }} + /> + + + {!error ? ( + !loading && modpacks.length == 0 ? ( +
+ +
+ No modpack has been found with the current filters. +
+
+ ) : ( + + {({ height, width }) => ( + + )} + + ) + ) : ( +
+ +
+ An error occurred while loading the modpacks list... +
+
+ )} +
+
+ ); +}; + +export default React.memo(ModrinthModpacks); + +const Container = styled.div` + width: 100%; + height: 100%; +`; + +const StyledInput = styled(Input.Search)``; + +const HeaderContainer = styled.div` + display: flex; + justify-content: center; +`; + +const ModpacksContainer = styled.div` + height: calc(100% - 15px); + overflow: hidden; + padding: 10px 0; +`; diff --git a/src/common/modals/CurseForgeModsBrowser.js b/src/common/modals/CurseForgeModsBrowser.js new file mode 100644 index 000000000..be4a5db61 --- /dev/null +++ b/src/common/modals/CurseForgeModsBrowser.js @@ -0,0 +1,597 @@ +/* eslint-disable no-nested-ternary */ +import React, { + memo, + useEffect, + useState, + forwardRef, + useContext +} from 'react'; +import { ipcRenderer } from 'electron'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import styled, { ThemeContext } from 'styled-components'; +import memoize from 'memoize-one'; +import InfiniteLoader from 'react-window-infinite-loader'; +import ContentLoader from 'react-content-loader'; +import { Input, Select, Button } from 'antd'; +import { useDispatch, useSelector } from 'react-redux'; +import { useDebouncedCallback } from 'use-debounce'; +import { FixedSizeList as List } from 'react-window'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCheckCircle } from '@fortawesome/free-regular-svg-icons'; +import { + faBomb, + faExclamationCircle, + faWrench, + faDownload +} from '@fortawesome/free-solid-svg-icons'; +import { getCurseForgeSearch, getAddonFiles } from '../api'; +import { openModal } from '../reducers/modals/actions'; +import { _getInstance } from '../utils/selectors'; +import { installMod } from '../reducers/actions'; +import { FABRIC, FORGE, CURSEFORGE } from '../utils/constants'; +import { + getFirstPreferredCandidate, + filterFabricFilesByVersion, + filterForgeFilesByVersion, + getPatchedInstanceType +} from '../../app/desktop/utils'; + +const RowContainer = styled.div` + display: flex; + position: relative; + justify-content: space-between; + align-items: center; + width: calc(100% - 30px) !important; + border-radius: 4px; + padding: 11px 21px; + background: ${props => props.theme.palette.grey[800]}; + ${props => + props.isInstalled && + `border: 2px solid ${props.theme.palette.colors.green};`} +`; + +const RowInnerContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + font-style: normal; + font-weight: bold; + font-size: 15px; + line-height: 18px; + color: ${props => props.theme.palette.text.secondary}; +`; + +const RowContainerImg = styled.div` + width: 38px; + height: 38px; + background-repeat: no-repeat; + background-size: cover; + background-position: center; + border-radius: 5px; + margin-right: 20px; +`; + +const ModInstalledIcon = styled(FontAwesomeIcon)` + position: absolute; + top: -10px; + left: -10px; + color: ${props => props.theme.palette.colors.green}; + font-size: 25px; + z-index: 1; +`; + +const ModsIconBg = styled.div` + position: absolute; + top: -10px; + left: -10px; + background: ${props => props.theme.palette.grey[800]}; + width: 25px; + height: 25px; + border-radius: 50%; + z-index: 0; +`; + +const ModsListWrapper = ({ + // Are there more items to load? + // (This information comes from the most recent API request.) + hasNextPage, + + // Are we currently loading a page of items? + // (This may be an in-flight flag in your Redux store for example.) + isNextPageLoading, + + // Array of items loaded so far. + items, + + // Callback function responsible for loading the next page of items. + loadNextPage, + searchQuery, + width, + height, + itemData +}) => { + // If there are more items to be loaded then add an extra row to hold a loading indicator. + const itemCount = hasNextPage ? items.length + 3 : items.length; + + // Only load 1 page of items at a time. + // Pass an empty callback to InfiniteLoader in case it asks us to load more than once. + + // const loadMoreItems = loadNextPage; + const loadMoreItems = isNextPageLoading ? () => {} : loadNextPage; + + // Every row is loaded except for our loading indicator row. + const isItemLoaded = index => !hasNextPage || index < items.length; + + const innerElementType = forwardRef(({ style, ...rest }, ref) => ( +
+ )); + + const Row = memo(({ index, style, data }) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const curseReleaseChannel = useSelector( + state => state.settings.curseReleaseChannel + ); + const dispatch = useDispatch(); + const { instanceName, gameVersions, installedMods, instance } = data; + + const item = items[index]; + + const isInstalled = installedMods.find(v => v.projectID === item?.id); + const primaryImage = item?.logo; + + if (!item) { + return ( + + ); + } + + const openModOverview = () => { + dispatch( + openModal('ModOverview', { + modSource: CURSEFORGE, + gameVersions, + projectID: item.id, + ...(isInstalled && { fileID: isInstalled.fileID }), + ...(isInstalled && { fileName: isInstalled.fileName }), + instanceName + }) + ); + }; + + return ( + + {isInstalled && } + {isInstalled && } + + + +
props.theme.palette.text.third}; + &:hover { + color: ${props => props.theme.palette.text.primary}; + } + transition: color 0.1s ease-in-out; + cursor: pointer; + `} + onClick={openModOverview} + > + {item.name} +
+
+ {!isInstalled ? ( + error || ( +
+ +
+ ) + ) : ( + + )} +
+ ); + }); + + return ( + loadMoreItems(searchQuery)} + threshold={20} + > + {({ onItemsRendered, ref }) => ( + + {Row} + + )} + + ); +}; + +const createItemData = memoize( + ( + items, + instanceName, + gameVersions, + installedMods, + instance, + isNextPageLoading + ) => ({ + items, + instanceName, + gameVersions, + installedMods, + instance, + isNextPageLoading + }) +); + +let lastRequest; +const CurseForgeModsBrowser = ({ instanceName, gameVersions }) => { + const itemsNumber = 50; + + const [mods, setMods] = useState([]); + const [areModsLoading, setAreModsLoading] = useState(true); + const [filterType, setFilterType] = useState('Featured'); + const [searchQuery, setSearchQuery] = useState(''); + const [hasNextPage, setHasNextPage] = useState(false); + const [categoryId, setCategoryId] = useState(null); + const [error, setError] = useState(false); + const instance = useSelector(state => _getInstance(state)(instanceName)); + const categories = useSelector(state => state.app.curseforgeCategories); + + const installedMods = instance?.mods; + + const loadMoreModsDebounced = useDebouncedCallback( + (s, reset) => { + loadMoreMods(s, reset); + }, + 500, + { leading: false, trailing: true } + ); + + useEffect(() => { + loadMoreMods(searchQuery, true); + }, [filterType, categoryId]); + + useEffect(() => { + loadMoreMods(); + }, []); + + const loadMoreMods = async (searchP = '', reset) => { + const reqObj = {}; + lastRequest = reqObj; + if (!areModsLoading) { + setAreModsLoading(true); + } + + const isReset = reset !== undefined ? reset : false; + let data = null; + try { + if (error) { + setError(false); + } + data = await getCurseForgeSearch( + 'mods', + searchP, + itemsNumber, + isReset ? 0 : mods.length, + filterType, + filterType !== 'Author' && filterType !== 'Name', + gameVersions, + categoryId, + getPatchedInstanceType(instance) + ); + } catch (err) { + console.error(err); + setError(err); + } + + const newMods = reset ? data : mods.concat(data); + if (lastRequest === reqObj) { + setAreModsLoading(false); + setMods(newMods || []); + setHasNextPage((newMods || []).length % itemsNumber === 0); + } + }; + + const itemData = createItemData( + mods, + instanceName, + gameVersions, + installedMods, + instance, + areModsLoading + ); + + return ( + +
+ + + { + setSearchQuery(e.target.value); + loadMoreModsDebounced(e.target.value, true); + }} + allowClear + /> +
+ + {!error ? ( + !areModsLoading && mods.length === 0 ? ( +
+ +
+ No mods has been found with the current filters. +
+
+ ) : ( + + {({ height, width }) => ( + + )} + + ) + ) : ( +
+ +
+ An error occurred while loading the mods list... +
+
+ )} +
+ ); +}; + +export default memo(CurseForgeModsBrowser); + +const ModsLoader = memo( + ({ width, top, isNextPageLoading, hasNextPage, loadNextPage }) => { + const ContextTheme = useContext(ThemeContext); + + useEffect(() => { + if (hasNextPage && isNextPageLoading) { + loadNextPage(); + } + }, []); + + return ( + + + + ); + } +); + +const Container = styled.div` + height: 100%; + width: 100%; +`; + +const Header = styled.div` + width: 100%; + height: 50px; + display: flex; + flex-direction: row; + justify-content: space-around; + align-items: center; +`; diff --git a/src/common/modals/InstanceDownloadFailed.js b/src/common/modals/InstanceDownloadFailed.js index c6d1a1b98..f6eadf00f 100644 --- a/src/common/modals/InstanceDownloadFailed.js +++ b/src/common/modals/InstanceDownloadFailed.js @@ -33,14 +33,17 @@ const InstanceDownloadFailed = ({ setLoading(true); await new Promise(resolve => setTimeout(resolve, 1000)); - await rollBackInstanceZip( - isUpdate, - instancesPath, - instanceName, - tempPath, - dispatch, - updateInstanceConfig - ); + // if instanceName is ever empty, this will delete ALL instances, so don't run it if we don't have a name + if (instanceName) { + await rollBackInstanceZip( + isUpdate, + instancesPath, + instanceName, + tempPath, + dispatch, + updateInstanceConfig + ); + } setLoading(false); dispatch(addNextInstanceToCurrentDownload()); diff --git a/src/common/modals/InstanceManager/Modpack.js b/src/common/modals/InstanceManager/Modpack.js index 378b71be5..cd34315b3 100644 --- a/src/common/modals/InstanceManager/Modpack.js +++ b/src/common/modals/InstanceManager/Modpack.js @@ -4,19 +4,23 @@ import { Select, Button } from 'antd'; import { useDispatch, useSelector } from 'react-redux'; import ReactHtmlParser from 'react-html-parser'; import path from 'path'; +import pMap from 'p-map'; import { getAddonFiles, getAddonFileChangelog, getFTBModpackData, getFTBChangelog, - getFTBModpackVersionData + getFTBModpackVersionData, + getModrinthProject, + getModrinthVersions } from '../../api'; import { changeModpackVersion } from '../../reducers/actions'; import { closeModal } from '../../reducers/modals/actions'; import { _getInstancesPath, _getTempPath } from '../../utils/selectors'; import { makeInstanceRestorePoint } from '../../utils'; +import { CURSEFORGE, FTB, MODRINTH } from '../../utils/constants'; -const Modpack = ({ modpackId, instanceName, manifest, fileID }) => { +const Modpack = ({ modpackId, instanceName, source, manifest, fileID }) => { const [files, setFiles] = useState([]); const [versionName, setVersionName] = useState(null); const [selectedIndex, setSelectedIndex] = useState(null); @@ -37,51 +41,101 @@ const Modpack = ({ modpackId, instanceName, manifest, fileID }) => { } }; + const convertModrinthReleaseType = type => { + switch (type) { + case 'release': + return 1; + case 'beta': + return 2; + default: + return 3; + } + }; + const initData = async () => { setLoading(true); - if (manifest) { - setVersionName(`${manifest?.name} - ${manifest?.version}`); - const data = await getAddonFiles(modpackId); - const mappedFiles = await Promise.all( - data.map(async v => { - const changelog = await getAddonFileChangelog(modpackId, v.id); - return { - ...v, - changelog - }; - }) - ); - setFiles(mappedFiles); - } else { - const ftbModpack = await getFTBModpackData(modpackId); - setVersionName( - `${ftbModpack.name} - ${ - ftbModpack.versions.find(modpack => modpack.id === fileID).name - }` - ); + switch (source) { + case CURSEFORGE: { + setVersionName(`${manifest?.name} - ${manifest?.version}`); + const data = await getAddonFiles(modpackId); + const mappedFiles = await Promise.all( + data.map(async v => { + const changelog = await getAddonFileChangelog(modpackId, v.id); + return { + ...v, + changelog + }; + }) + ); + setFiles(mappedFiles); + break; + } + case FTB: { + const ftbModpack = await getFTBModpackData(modpackId); - const mappedVersions = await Promise.all( - ftbModpack.versions.map(async version => { - const changelog = await getFTBChangelog(modpackId, version.id); - const newModpack = await getFTBModpackVersionData( - modpackId, - version.id - ); + setVersionName( + `${ftbModpack.name} - ${ + ftbModpack.versions.find(modpack => modpack.id === fileID).name + }` + ); + + const mappedVersions = await Promise.all( + ftbModpack.versions.map(async version => { + const changelog = await getFTBChangelog(modpackId, version.id); + const newModpack = await getFTBModpackVersionData( + modpackId, + version.id + ); + return { + displayName: `${ftbModpack.name} ${version.name}`, + id: version.id, + gameVersions: [newModpack.targets[1]?.version], + releaseType: convertFtbReleaseType(version.type), + fileDate: version.updated * 1000, + imageUrl: ftbModpack.art[0].url, + changelog: changelog.content + }; + }) + ); + + setFiles(mappedVersions); + break; + } + case MODRINTH: { + const modpack = await getModrinthProject(modpackId); + const versions = (await getModrinthVersions(modpack.versions)).sort( + (a, b) => Date.parse(b.date_published) - Date.parse(a.date_published) + ); + setVersionName( + `${modpack.name} - ${ + versions.find(version => version.id === fileID).name + }` + ); + + const mappedVersions = await pMap(versions, async version => { return { - displayName: `${ftbModpack.name} ${version.name}`, + displayName: `${modpack.name} ${version.name}`, id: version.id, - gameVersions: [newModpack.targets[1]?.version], - releaseType: convertFtbReleaseType(version.type), - fileDate: version.updated * 1000, - imageUrl: ftbModpack.art[0].url, - changelog: changelog.content + gameVersions: version.game_versions, + releaseType: convertModrinthReleaseType(version.version_type), + fileDate: Date.parse(version.date_published), + imageUrl: + modpack.gallery?.find(img => img.featured)?.url || + modpack.gallery?.at(0)?.url || + modpack.icon_url || + '', + changelog: version.changelog }; - }) - ); + }); - setFiles(mappedVersions); + setFiles(mappedVersions || []); + break; + } + default: { + throw Error(`Unknown modpack source: ${source}`); + } } setLoading(false); }; @@ -147,7 +201,7 @@ const Modpack = ({ modpackId, instanceName, manifest, fileID }) => { disabled={loading} virtual={false} > - {(files || []).map((file, index) => ( + {files.map((file, index) => (
{
{ - if (!item.fileID) return; - dispatch( - openModal('ModOverview', { - projectID: item.projectID, - fileID: item.fileID, - fileName: item.fileName, - gameVersions, - instanceName - }) - ); + if (item.fileID) { + dispatch( + openModal('ModOverview', { + modSource: item.modSource, + projectID: item.projectID, + fileID: item.fileID, + fileName: item.fileName, + gameVersions, + instanceName + }) + ); + } else { + console.error( + `Mod "${name}" does not have a valid file/version ID. Cannot open Mod Overview.` + ); + } }} className="rowCenterContent" > @@ -686,24 +692,18 @@ const Mods = ({ instanceName }) => { }; const menu = ( - { - dispatch(openModal('ModsUpdater', { instanceName })); - setIsMenuOpen(false); - }} - > - Update All Mods -
- ) - } - ]} - /> + + { + dispatch(openModal('ModsUpdater', { instanceName })); + setIsMenuOpen(false); + }} + > + Update All Mods + + ); return ( diff --git a/src/common/modals/InstanceManager/Overview.js b/src/common/modals/InstanceManager/Overview.js index 24dcbaf1a..95ea19a7f 100644 --- a/src/common/modals/InstanceManager/Overview.js +++ b/src/common/modals/InstanceManager/Overview.js @@ -185,7 +185,9 @@ const Overview = ({ instanceName, background, manifest }) => { const defaultJavaPath = useSelector(state => _getJavaPath(state)(javaVersion) ); - const [javaLocalMemory, setJavaLocalMemory] = useState(config?.javaMemory); + const [javaLocalMemory, setJavaLocalMemory] = useState( + scaleMem(config?.javaMemory) + ); const [javaLocalArguments, setJavaLocalArguments] = useState( config?.javaArgs ); diff --git a/src/common/modals/InstanceManager/index.js b/src/common/modals/InstanceManager/index.js index 4e4dfa7c7..ef9d21969 100644 --- a/src/common/modals/InstanceManager/index.js +++ b/src/common/modals/InstanceManager/index.js @@ -424,6 +424,7 @@ const InstanceManager = ({ instanceName }) => { modpackId={instance?.loader?.projectID} fileID={instance?.loader?.fileID} background={background} + source={instance?.loader?.source} manifest={manifest} /> diff --git a/src/common/modals/ModChangelog.js b/src/common/modals/ModChangelog.js index 9d103d5e2..b4745b8af 100644 --- a/src/common/modals/ModChangelog.js +++ b/src/common/modals/ModChangelog.js @@ -5,10 +5,15 @@ import ReactHtmlParser from 'react-html-parser'; import { Select } from 'antd'; import ReactMarkdown from 'react-markdown'; import Modal from '../components/Modal'; -import { getAddonFileChangelog, getFTBChangelog } from '../api'; +import { + getAddonFileChangelog, + getFTBChangelog, + getModrinthVersionChangelog +} from '../api'; +import { CURSEFORGE, FTB, MODRINTH } from '../utils/constants'; let latest = {}; -const ModChangelog = ({ modpackId, files, type, modpackName }) => { +const ModChangelog = ({ projectID, files, type, projectName }) => { const [changelog, setChangelog] = useState(null); const [loading, setLoading] = useState(false); const [selectedId, setSelectedId] = useState(null); @@ -19,10 +24,18 @@ const ModChangelog = ({ modpackId, files, type, modpackName }) => { setLoading(true); let data; try { - if (type === 'ftb') { - data = await getFTBChangelog(modpackId, id); - } else { - data = await getAddonFileChangelog(modpackId, id); + switch (type) { + case FTB: + data = await getFTBChangelog(projectID, id); + break; + case CURSEFORGE: + data = await getAddonFileChangelog(projectID, id); + break; + case MODRINTH: + data = await getModrinthVersionChangelog(id); + break; + default: + throw Error(`Unknown type: ${type}`); } } catch (err) { console.error(err); @@ -78,12 +91,16 @@ const ModChangelog = ({ modpackId, files, type, modpackName }) => { {(files || []).map(v => ( - {type === 'ftb' ? `${modpackName} - ${v.name}` : v.displayName} + {type === 'ftb' || type === MODRINTH + ? `${projectName} - ${v.name}` + : v.displayName} ))} @@ -96,23 +113,23 @@ const ModChangelog = ({ modpackId, files, type, modpackName }) => { margin-bottom: 40px; `} > - {type === 'ftb' - ? `${modpackName} - ${ + {type === 'ftb' || type === MODRINTH + ? `${projectName} - ${ (files || []).find(v => v.id === selectedId)?.name }` : (files || []).find(v => v.id === selectedId)?.displayName}
- {type === 'ftb' ? ( + {type === CURSEFORGE ? ( + ReactHtmlParser(changelog) + ) : ( - {changelog.content} + {changelog.content || changelog} - ) : ( - ReactHtmlParser(changelog) )} ) : ( diff --git a/src/common/modals/ModOverview.js b/src/common/modals/ModOverview.js index 7d281d095..df3e8a906 100644 --- a/src/common/modals/ModOverview.js +++ b/src/common/modals/ModOverview.js @@ -3,19 +3,33 @@ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; import { useDispatch, useSelector } from 'react-redux'; import ReactHtmlParser from 'react-html-parser'; +import ReactMarkdown from 'react-markdown'; import path from 'path'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faExternalLinkAlt, faInfo } from '@fortawesome/free-solid-svg-icons'; import { Button, Select } from 'antd'; import Modal from '../components/Modal'; import { transparentize } from 'polished'; -import { getAddonDescription, getAddonFiles, getAddon } from '../api'; +import { + getAddonDescription, + getAddonFiles, + getAddon, + getModrinthProject, + getModrinthVersions, + getModrinthUser +} from '../api'; import CloseButton from '../components/CloseButton'; import { closeModal, openModal } from '../reducers/modals/actions'; import { installMod, updateInstanceConfig } from '../reducers/actions'; import { remove } from 'fs-extra'; import { _getInstancesPath, _getInstance } from '../utils/selectors'; -import { FABRIC, FORGE, CURSEFORGE_URL } from '../utils/constants'; +import { + FABRIC, + FORGE, + CURSEFORGE_URL, + CURSEFORGE, + MODRINTH +} from '../utils/constants'; import { formatNumber, formatDate } from '../utils'; import { filterFabricFilesByVersion, @@ -24,6 +38,7 @@ import { } from '../../app/desktop/utils'; const ModOverview = ({ + modSource, projectID, fileID, gameVersions, @@ -32,7 +47,15 @@ const ModOverview = ({ }) => { const dispatch = useDispatch(); const [description, setDescription] = useState(null); + // curseforge only const [addon, setAddon] = useState(null); + // modrinth only + const [mod, setMod] = useState(null); + const [author, setAuthor] = useState(''); + const [downloadCount, setDownloadCount] = useState(0); + const [updatedDate, setUpdatedDate] = useState(0); + const [gameVersion, setGameVersion] = useState(''); + const [url, setUrl] = useState(''); const [files, setFiles] = useState([]); const [selectedItem, setSelectedItem] = useState(fileID); const [installedData, setInstalledData] = useState({ fileID, fileName }); @@ -44,32 +67,63 @@ const ModOverview = ({ useEffect(() => { const init = async () => { setLoadingFiles(true); - await Promise.all([ - getAddon(projectID).then(data => setAddon(data)), - getAddonDescription(projectID).then(data => { - // Replace the beginning of all relative URLs with the Curseforge URL - const modifiedData = data.replace( - /href="(?!http)/g, - `href="${CURSEFORGE_URL}` - ); - setDescription(modifiedData); - }), - getAddonFiles(projectID).then(async data => { - const isFabric = - getPatchedInstanceType(instance) === FABRIC && projectID !== 361988; - const isForge = - getPatchedInstanceType(instance) === FORGE || projectID === 361988; - let filteredFiles = []; - if (isFabric) { - filteredFiles = filterFabricFilesByVersion(data, gameVersions); - } else if (isForge) { - filteredFiles = filterForgeFilesByVersion(data, gameVersions); - } - setFiles(filteredFiles); - setLoadingFiles(false); - }) - ]); + if (modSource === CURSEFORGE) { + await Promise.all([ + getAddon(projectID).then(addon => { + setAddon(addon); + setAuthor(addon.author || addon.authors?.at(0).name); + setDownloadCount(addon.downloadCount); + setUpdatedDate(Date.parse(addon.dateModified)); + setGameVersion(addon.latestFilesIndexes[0].gameVersion); + setUrl(addon.links?.websiteUrl); + }), + getAddonDescription(projectID).then(data => { + // Replace the beginning of all relative URLs with the Curseforge URL + const modifiedData = data.replace( + /href="(?!http)/g, + `href="${CURSEFORGE_URL}` + ); + setDescription(modifiedData); + }), + getAddonFiles(projectID).then(async data => { + const isFabric = + getPatchedInstanceType(instance) === FABRIC && + projectID !== 361988; + const isForge = + getPatchedInstanceType(instance) === FORGE || + projectID === 361988; + let filteredFiles = []; + if (isFabric) { + filteredFiles = filterFabricFilesByVersion(data, gameVersions); + } else if (isForge) { + filteredFiles = filterForgeFilesByVersion(data, gameVersions); + } + + setFiles(filteredFiles); + setLoadingFiles(false); + }) + ]); + } else if (modSource === MODRINTH) { + const project = await getModrinthProject(projectID); + setMod(project); + + setDescription(project.body); + const versions = ( + await getModrinthVersions(project.versions) + ).sort( + (a, b) => Date.parse(b.date_published) - Date.parse(a.date_published) + ); + setFiles(versions); + setLoadingFiles(false); + getModrinthUser(versions[0].author_id).then(user => { + setAuthor(user.username); + }); + setDownloadCount(project.downloads); + setUpdatedDate(Date.parse(project.updated)); + setGameVersion(versions[0].game_versions[0]); + setUrl(`https://modrinth.com/mod/${project.slug}`); + } }; init(); @@ -86,8 +140,11 @@ const ModOverview = ({ }; const getReleaseType = id => { + if (typeof id === 'string') id = id.toUpperCase(); + switch (id) { case 1: + case 'RELEASE': return ( ); case 2: + case 'BETA': return ( ); case 3: - default: + case 'ALPHA': return ( ); + default: + return ( + props.theme.palette.colors.red}; + `} + > + [Unknown] + + ); } }; const handleChange = value => setSelectedItem(JSON.parse(value)); - const primaryImage = addon?.logo; + + const primaryImage = addon?.logo || mod?.icon_url; return ( dispatch(closeModal())} /> - + - {addon?.name} + {addon?.name || mod?.name}
- {addon?.authors[0].name} + {author}
- {addon?.downloadCount && ( + {downloadCount && (
- {formatNumber(addon?.downloadCount)} + {formatNumber(downloadCount)}
)}
- {' '} - {formatDate(addon?.dateModified)} + {formatDate(updatedDate)}
- {addon?.latestFilesIndexes[0]?.gameVersion} + {gameVersion}
)} {(files || []).map(file => ( @@ -252,7 +327,7 @@ const ModOverview = ({ align-items: center; `} > - {file.displayName} + {file.displayName || file.name}
-
{gameVersions}
-
{getReleaseType(file.releaseType)}
+
+ {modSource === CURSEFORGE + ? gameVersions + : file.game_versions[0]} +
+
+ {getReleaseType(file.releaseType || file.version_type)} +
- {new Date(file.fileDate).toLocaleDateString(undefined, { + {new Date( + file.fileDate || file.date_published + ).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' diff --git a/src/common/modals/ModpackDescription.js b/src/common/modals/ModpackDescription.js index 7886c5b6c..ba9c4532e 100644 --- a/src/common/modals/ModpackDescription.js +++ b/src/common/modals/ModpackDescription.js @@ -10,10 +10,22 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Checkbox, TextField, Cascader, Button, Input, Select } from 'antd'; import Modal from '../components/Modal'; import { transparentize } from 'polished'; -import { getAddonDescription, getAddonFiles } from '../api'; +import { + getAddonDescription, + getAddonFiles, + getModrinthVersions, + getModrinthUser +} from '../api'; import CloseButton from '../components/CloseButton'; import { closeModal, openModal } from '../reducers/modals/actions'; -import { FORGE, CURSEFORGE_URL, FTB_MODPACK_URL } from '../utils/constants'; +import { + FORGE, + CURSEFORGE_URL, + FTB_MODPACK_URL, + MODRINTH, + CURSEFORGE, + FTB +} from '../utils/constants'; import { formatNumber, formatDate } from '../utils'; const ModpackDescription = ({ @@ -28,30 +40,73 @@ const ModpackDescription = ({ const [files, setFiles] = useState(null); const [selectedId, setSelectedId] = useState(false); const [loading, setLoading] = useState(true); + const [author, setAuthor] = useState(''); + const [downloadCount, setDownloadCount] = useState(0); + const [updatedDate, setUpdatedDate] = useState(0); + const [gameVersion, setGameVersion] = useState(''); + const [url, setUrl] = useState(''); useEffect(() => { const init = async () => { setLoading(true); - if (type === 'curseforge') { - await Promise.all([ - getAddonDescription(modpack.id).then(data => { - // Replace the beginning of all relative URLs with the Curseforge URL - const modifiedData = data.replace( - /href="(?!http)/g, - `href="${CURSEFORGE_URL}` - ); - setDescription(modifiedData); - }), - getAddonFiles(modpack.id).then(async data => { - setFiles(data); - setLoading(false); - }) - ]); - } else if (type === 'ftb') { - setDescription(modpack.description); - setFiles(modpack.versions.slice().reverse()); - setLoading(false); + switch (type) { + case CURSEFORGE: { + await Promise.all([ + getAddonDescription(modpack.id).then(data => { + // Replace the beginning of all relative URLs with the Curseforge URL + const modifiedData = data.replace( + /href="(?!http)/g, + `href="${CURSEFORGE_URL}` + ); + + setDescription(modifiedData); + }), + getAddonFiles(modpack.id).then(data => { + setFiles(data); + setAuthor(modpack.author || modpack.authors?.at(0).name); + setDownloadCount(modpack.downloadCount); + setUpdatedDate(Date.parse(modpack.dateModified)); + setGameVersion(modpack.latestFilesIndexes[0].gameVersion); + setUrl(modpack.websiteUrl); + }) + ]); + + setLoading(false); + break; + } + case FTB: { + setDescription(modpack.description); + setFiles(modpack.versions.slice().reverse()); + setAuthor(modpack.author || modpack.authors?.at(0).name); + setDownloadCount(modpack.installs); + setUpdatedDate(modpack.refreshed * 1000); + setGameVersion(modpack.tags[0]?.name || '-'); + setUrl(parseLink(modpack.name)); + + setLoading(false); + break; + } + case MODRINTH: { + setDescription(modpack.body); + const versions = ( + await getModrinthVersions(modpack.versions) + ).sort( + (a, b) => + Date.parse(b.date_published) - Date.parse(a.date_published) + ); + setFiles(versions); + getModrinthUser(versions[0].author_id).then(user => { + setAuthor(user.username); + }); + setDownloadCount(modpack.downloads); + setUpdatedDate(Date.parse(modpack.updated)); + setGameVersion(versions[0].game_versions[0]); + setUrl(`https://modrinth.com/modpack/${modpack.slug}`); + + setLoading(false); + break; + } } }; init(); @@ -60,9 +115,10 @@ const ModpackDescription = ({ const handleChange = value => setSelectedId(value); const getReleaseType = id => { + if (typeof id === 'string') id = id.toUpperCase(); switch (id) { case 1: - case 'Release': + case 'RELEASE': return ( ); case 2: - case 'Beta': + case 'BETA': return ( ); case 3: - case 'Alpha': + case 'ALPHA': default: return ( img.featured)?.url || + modpack.gallery?.at(0)?.url || + modpack.icon_url || + '' + ); } }, [modpack, type]); @@ -140,33 +203,23 @@ const ModpackDescription = ({
- {modpack.authors[0].name} + {author}
- {type === 'ftb' - ? formatNumber(modpack.installs) - : formatNumber(modpack.downloadCount)} + {downloadCount}
- {type === 'ftb' - ? formatDate(modpack.refreshed * 1000) - : formatDate(modpack.dateModified)} + {formatDate(updatedDate)}
- {type === 'ftb' - ? modpack.tags[0]?.name || '-' - : modpack.latestFilesIndexes[0].gameVersion} + {gameVersion}
{type === 'ftb' ? modpack.tags[0]?.name || '-' - : file.gameVersions[0]} + : type === CURSEFORGE + ? file.gameVersions[0] + : file.game_versions.sort().at(-1)}
{getReleaseType( - type === 'ftb' ? file.type : file.releaseType + type === 'ftb' + ? file.type + : type === CURSEFORGE + ? file.releaseType + : file.version_type )}
@@ -288,7 +347,11 @@ const ModpackDescription = ({ >
{new Date( - type === 'ftb' ? file.updated * 1000 : file.fileDate + type === 'ftb' + ? file.updated * 1000 + : type === CURSEFORGE + ? file.fileDate + : file.date_published ).toLocaleDateString(undefined, { year: 'numeric', month: 'long', diff --git a/src/common/modals/ModrinthModsBrowser.js b/src/common/modals/ModrinthModsBrowser.js new file mode 100644 index 000000000..0f0870b23 --- /dev/null +++ b/src/common/modals/ModrinthModsBrowser.js @@ -0,0 +1,589 @@ +/* eslint-disable no-nested-ternary */ +import React, { + memo, + useEffect, + useState, + forwardRef, + useContext +} from 'react'; +import { ipcRenderer } from 'electron'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import styled, { ThemeContext } from 'styled-components'; +import memoize from 'memoize-one'; +import InfiniteLoader from 'react-window-infinite-loader'; +import ContentLoader from 'react-content-loader'; +import { Input, Select, Button } from 'antd'; +import { useDispatch, useSelector } from 'react-redux'; +import { useDebouncedCallback } from 'use-debounce'; +import { FixedSizeList as List } from 'react-window'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCheckCircle } from '@fortawesome/free-regular-svg-icons'; +import { + faBomb, + faExclamationCircle, + faWrench, + faDownload +} from '@fortawesome/free-solid-svg-icons'; +import { getModrinthSearchResults, getModrinthProjectVersions } from '../api'; +import { openModal } from '../reducers/modals/actions'; +import { _getInstance } from '../utils/selectors'; +import { installModrinthMod } from '../reducers/actions'; +import { MODRINTH } from '../utils/constants'; + +const RowContainer = styled.div` + display: flex; + position: relative; + justify-content: space-between; + align-items: center; + width: calc(100% - 30px) !important; + border-radius: 4px; + padding: 11px 21px; + background: ${props => props.theme.palette.grey[800]}; + ${props => + props.isInstalled && + `border: 2px solid ${props.theme.palette.colors.green};`} +`; + +const RowInnerContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + font-style: normal; + font-weight: bold; + font-size: 15px; + line-height: 18px; + color: ${props => props.theme.palette.text.secondary}; +`; + +const RowContainerImg = styled.div` + width: 38px; + height: 38px; + background-repeat: no-repeat; + background-size: cover; + background-position: center; + border-radius: 5px; + margin-right: 20px; +`; + +const ModInstalledIcon = styled(FontAwesomeIcon)` + position: absolute; + top: -10px; + left: -10px; + color: ${props => props.theme.palette.colors.green}; + font-size: 25px; + z-index: 1; +`; + +const ModsIconBg = styled.div` + position: absolute; + top: -10px; + left: -10px; + background: ${props => props.theme.palette.grey[800]}; + width: 25px; + height: 25px; + border-radius: 50%; + z-index: 0; +`; + +const ModsListWrapper = ({ + // Are there more items to load? + // (This information comes from the most recent API request.) + hasNextPage, + + // Are we currently loading a page of items? + // (This may be an in-flight flag in your Redux store for example.) + isNextPageLoading, + + // Array of items loaded so far. + items, + + // Callback function responsible for loading the next page of items. + loadNextPage, + searchQuery, + width, + height, + itemData +}) => { + // If there are more items to be loaded then add an extra row to hold a loading indicator. + const itemCount = hasNextPage ? items.length + 3 : items.length; + + // Only load 1 page of items at a time. + // Pass an empty callback to InfiniteLoader in case it asks us to load more than once. + + // const loadMoreItems = loadNextPage; + const loadMoreItems = isNextPageLoading ? () => {} : loadNextPage; + + // Every row is loaded except for our loading indicator row. + const isItemLoaded = index => !hasNextPage || index < items.length; + + const innerElementType = forwardRef(({ style, ...rest }, ref) => ( +
+ )); + + const Row = memo(({ index, style, data }) => { + const [loading, setLoading] = useState(false); + const [error] = useState(null); + const dispatch = useDispatch(); + + const { instanceName, gameVersion, installedMods } = data; + + const item = items[index]; + + const isInstalled = installedMods.find( + v => v.projectID === item?.project_id + ); + const iconUrl = item?.icon_url || ''; + + if (!item) { + return ( + + ); + } + + const openModOverview = () => { + dispatch( + openModal('ModOverview', { + modSource: MODRINTH, + gameVersion, + projectID: item.project_id, + ...(isInstalled && { fileID: isInstalled.fileID }), + ...(isInstalled && { fileName: isInstalled.fileName }), + instanceName + }) + ); + }; + + return ( + + {isInstalled && } + {isInstalled && } + + + +
props.theme.palette.text.third}; + &:hover { + color: ${props => props.theme.palette.text.primary}; + } + transition: color 0.1s ease-in-out; + cursor: pointer; + `} + onClick={openModOverview} + > + {item.title} +
+
+ {!isInstalled ? ( + error || ( +
+ +
+ ) + ) : ( + + )} +
+ ); + }); + + return ( + loadMoreItems(searchQuery)} + threshold={20} + > + {({ onItemsRendered, ref }) => ( + + {Row} + + )} + + ); +}; + +const createItemData = memoize( + ( + items, + instanceName, + gameVersion, + installedMods, + instance, + isNextPageLoading + ) => ({ + items, + instanceName, + gameVersion, + installedMods, + instance, + isNextPageLoading + }) +); + +const ModrinthModsBrowser = ({ instanceName, gameVersion }) => { + const [mods, setMods] = useState([]); + const [areModsLoading, setAreModsLoading] = useState(true); + const [filterType, setFilterType] = useState('relevance'); + const [searchQuery, setSearchQuery] = useState(''); + const [hasNextPage, setHasNextPage] = useState(false); + const [categoryId, setCategoryId] = useState(undefined); + const [error, setError] = useState(false); + const instance = useSelector(state => _getInstance(state)(instanceName)); + const categories = useSelector(state => + state.app.modrinthCategories + .filter(cat => cat.project_type === 'mod') + .map(cat => { + return { + id: cat.name, + displayName: cat.name[0].toUpperCase() + cat.name.slice(1), + icon: cat.icon + .replace('xmlns="http://www.w3.org/2000/svg"', '') + .replace(' { + loadMoreMods(s, reset); + }, + 500, + { leading: false, trailing: true } + ); + + useEffect(() => { + loadMoreMods(searchQuery, true); + }, [filterType, categoryId]); + + useEffect(() => { + loadMoreMods(); + }, []); + + const loadMoreMods = async (query = '', reset) => { + const isReset = reset !== undefined ? reset : false; + setAreModsLoading(true); + setError(false); + + let hits; + let totalHits; + + try { + // this only supports filtering by 1 category, but the API supports multiple if we want to include that later + ({ hits, total_hits: totalHits } = await getModrinthSearchResults( + query, + 'MOD', + gameVersion, + [categoryId], + filterType, + isReset ? 0 : mods.length + )); + } catch (err) { + console.error(err); + setError(err); + } + + const newMods = reset ? hits : [...mods, ...hits]; + + setHasNextPage(newMods?.length < totalHits); + setMods(newMods || []); + setAreModsLoading(false); + }; + + const itemData = createItemData( + mods, + instanceName, + gameVersion, + installedMods, + instance, + areModsLoading + ); + + return ( + +
+ + + { + setSearchQuery(e.target.value); + loadMoreModsDebounced(e.target.value, true); + }} + allowClear + /> +
+ + {!error ? ( + !areModsLoading && mods.length === 0 ? ( +
+ +
+ No mods has been found with the current filters. +
+
+ ) : ( + + {({ height, width }) => ( + + )} + + ) + ) : ( +
+ +
+ An error occurred while loading the mods list... +
+
+ )} +
+ ); +}; + +export default memo(ModrinthModsBrowser); + +const ModsLoader = memo( + ({ width, top, isNextPageLoading, hasNextPage, loadNextPage }) => { + const ContextTheme = useContext(ThemeContext); + + useEffect(() => { + if (hasNextPage && isNextPageLoading) { + loadNextPage(); + } + }, []); + + return ( + + + + ); + } +); + +const Container = styled.div` + height: 100%; + width: 100%; +`; + +const Header = styled.div` + width: 100%; + height: 50px; + display: flex; + flex-direction: row; + justify-content: space-around; + align-items: center; +`; diff --git a/src/common/modals/ModsBrowser.js b/src/common/modals/ModsBrowser.js index 4a283fb69..966ab11ad 100644 --- a/src/common/modals/ModsBrowser.js +++ b/src/common/modals/ModsBrowser.js @@ -1,427 +1,16 @@ /* eslint-disable no-nested-ternary */ -import React, { - memo, - useEffect, - useState, - forwardRef, - useContext -} from 'react'; -import { ipcRenderer } from 'electron'; -import AutoSizer from 'react-virtualized-auto-sizer'; -import styled, { ThemeContext } from 'styled-components'; -import memoize from 'memoize-one'; -import InfiniteLoader from 'react-window-infinite-loader'; -import ContentLoader from 'react-content-loader'; -import { Input, Select, Button } from 'antd'; -import { useDispatch, useSelector } from 'react-redux'; -import { useDebouncedCallback } from 'use-debounce'; -import { FixedSizeList as List } from 'react-window'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCheckCircle } from '@fortawesome/free-regular-svg-icons'; -import { - faBomb, - faExclamationCircle, - faWrench, - faDownload -} from '@fortawesome/free-solid-svg-icons'; +import React, { memo, useState } from 'react'; +import styled from 'styled-components'; +import { Radio } from 'antd'; import Modal from '../components/Modal'; -import { getSearch, getAddonFiles } from '../api'; -import { openModal } from '../reducers/modals/actions'; -import { _getInstance } from '../utils/selectors'; -import { installMod } from '../reducers/actions'; -import { FABRIC, FORGE } from '../utils/constants'; -import { - getFirstPreferredCandidate, - filterFabricFilesByVersion, - filterForgeFilesByVersion, - getPatchedInstanceType -} from '../../app/desktop/utils'; +import { CURSEFORGE, MODRINTH } from '../utils/constants'; +import CurseForgeModsBrowser from './CurseForgeModsBrowser'; +import ModrinthModsBrowser from './ModrinthModsBrowser'; +import curseForgeIcon from '../assets/curseforgeIcon.webp'; +import modrinthIcon from '../assets/modrinthIcon.webp'; -const RowContainer = styled.div` - display: flex; - position: relative; - justify-content: space-between; - align-items: center; - width: calc(100% - 30px) !important; - border-radius: 4px; - padding: 11px 21px; - background: ${props => props.theme.palette.grey[800]}; - ${props => - props.isInstalled && - `border: 2px solid ${props.theme.palette.colors.green};`} -`; - -const RowInnerContainer = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - font-style: normal; - font-weight: bold; - font-size: 15px; - line-height: 18px; - color: ${props => props.theme.palette.text.secondary}; -`; - -const RowContainerImg = styled.div` - width: 38px; - height: 38px; - background-repeat: no-repeat; - background-size: cover; - background-position: center; - border-radius: 5px; - margin-right: 20px; -`; - -const ModInstalledIcon = styled(FontAwesomeIcon)` - position: absolute; - top: -10px; - left: -10px; - color: ${props => props.theme.palette.colors.green}; - font-size: 25px; - z-index: 1; -`; - -const ModsIconBg = styled.div` - position: absolute; - top: -10px; - left: -10px; - background: ${props => props.theme.palette.grey[800]}; - width: 25px; - height: 25px; - border-radius: 50%; - z-index: 0; -`; - -const ModsListWrapper = ({ - // Are there more items to load? - // (This information comes from the most recent API request.) - hasNextPage, - - // Are we currently loading a page of items? - // (This may be an in-flight flag in your Redux store for example.) - isNextPageLoading, - - // Array of items loaded so far. - items, - - // Callback function responsible for loading the next page of items. - loadNextPage, - searchQuery, - width, - height, - itemData -}) => { - // If there are more items to be loaded then add an extra row to hold a loading indicator. - const itemCount = hasNextPage ? items.length + 3 : items.length; - - // Only load 1 page of items at a time. - // Pass an empty callback to InfiniteLoader in case it asks us to load more than once. - - // const loadMoreItems = loadNextPage; - const loadMoreItems = isNextPageLoading ? () => {} : loadNextPage; - - // Every row is loaded except for our loading indicator row. - const isItemLoaded = index => !hasNextPage || index < items.length; - - const innerElementType = forwardRef(({ style, ...rest }, ref) => ( -
- )); - - const Row = memo(({ index, style, data }) => { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const curseReleaseChannel = useSelector( - state => state.settings.curseReleaseChannel - ); - const dispatch = useDispatch(); - const { instanceName, gameVersions, installedMods, instance } = data; - - const item = items[index]; - - const isInstalled = installedMods.find(v => v.projectID === item?.id); - const primaryImage = item?.logo; - - if (!item) { - return ( - - ); - } - - return ( - - {isInstalled && } - {isInstalled && } - - - -
props.theme.palette.text.third}; - &:hover { - color: ${props => props.theme.palette.text.primary}; - } - transition: color 0.1s ease-in-out; - cursor: pointer; - `} - onClick={() => { - dispatch( - openModal('ModOverview', { - gameVersions, - projectID: item.id, - ...(isInstalled && { fileID: isInstalled.fileID }), - ...(isInstalled && { fileName: isInstalled.fileName }), - instanceName - }) - ); - }} - > - {item.name} -
-
- {!isInstalled ? ( - error || ( -
- -
- ) - ) : ( - - )} -
- ); - }); - - return ( - loadMoreItems(searchQuery)} - threshold={20} - > - {({ onItemsRendered, ref }) => ( - - {Row} - - )} - - ); -}; - -const createItemData = memoize( - ( - items, - instanceName, - gameVersions, - installedMods, - instance, - isNextPageLoading - ) => ({ - items, - instanceName, - gameVersions, - installedMods, - instance, - isNextPageLoading - }) -); - -let lastRequest; const ModsBrowser = ({ instanceName, gameVersions }) => { - const itemsNumber = 50; - - const [mods, setMods] = useState([]); - const [areModsLoading, setAreModsLoading] = useState(true); - const [filterType, setFilterType] = useState('Featured'); - const [searchQuery, setSearchQuery] = useState(''); - const [hasNextPage, setHasNextPage] = useState(false); - const [categoryId, setCategoryId] = useState(null); - const [error, setError] = useState(false); - const instance = useSelector(state => _getInstance(state)(instanceName)); - const categories = useSelector(state => state.app.curseforgeCategories); - - const installedMods = instance?.mods; - - const loadMoreModsDebounced = useDebouncedCallback( - (s, reset) => { - loadMoreMods(s, reset); - }, - 500, - { leading: false, trailing: true } - ); - - useEffect(() => { - loadMoreMods(searchQuery, true); - }, [filterType, categoryId]); - - useEffect(() => { - loadMoreMods(); - }, []); - - const loadMoreMods = async (searchP = '', reset) => { - const reqObj = {}; - lastRequest = reqObj; - if (!areModsLoading) { - setAreModsLoading(true); - } - - const isReset = reset !== undefined ? reset : false; - let data = null; - try { - if (error) { - setError(false); - } - data = await getSearch( - 'mods', - searchP, - itemsNumber, - isReset ? 0 : mods.length, - filterType, - filterType !== 'Author' && filterType !== 'Name', - gameVersions, - categoryId, - getPatchedInstanceType(instance) - ); - } catch (err) { - setError(err); - } - - const newMods = reset ? data : mods.concat(data); - if (lastRequest === reqObj) { - setAreModsLoading(false); - setMods(newMods || []); - setHasNextPage((newMods || []).length % itemsNumber === 0); - } - }; - - const itemData = createItemData( - mods, - instanceName, - gameVersions, - installedMods, - instance, - areModsLoading - ); + const [modSource, setModSource] = useState(CURSEFORGE); return ( { width: 90%; max-width: 1500px; `} - title="Instance Manager" + title="Mods Browser" >
- - - { - setSearchQuery(e.target.value); - loadMoreModsDebounced(e.target.value, true); - }} - allowClear - /> -
- - {!error ? ( - !areModsLoading && mods.length === 0 ? ( -
- -
+ CurseForge - No mods has been found with the current filters. -
-
- ) : ( - - {({ height, width }) => ( - - )} - - ) - ) : ( -
- -
- An error occurred while loading the mods list... -
-
- )} + /> + CurseForge + + + Modrinth + Modrinth + + + + + {modSource === CURSEFORGE ? ( + + ) : modSource === MODRINTH ? ( + + ) : null}
); @@ -573,36 +72,6 @@ const ModsBrowser = ({ instanceName, gameVersions }) => { export default memo(ModsBrowser); -const ModsLoader = memo( - ({ width, top, isNextPageLoading, hasNextPage, loadNextPage }) => { - const ContextTheme = useContext(ThemeContext); - - useEffect(() => { - if (hasNextPage && isNextPageLoading) { - loadNextPage(); - } - }, []); - - return ( - - - - ); - } -); - const Container = styled.div` height: 100%; width: 100%; diff --git a/src/common/reducers/actionTypes.js b/src/common/reducers/actionTypes.js index 54257085d..abdc037b8 100644 --- a/src/common/reducers/actionTypes.js +++ b/src/common/reducers/actionTypes.js @@ -33,6 +33,8 @@ export const UPDATE_FORGE_MANIFEST = 'UPDATE_FORGE_MANIFEST'; export const UPDATE_CURSEFORGE_CATEGORIES_MANIFEST = 'UPDATE_CURSEFORGE_CATEGORIES_MANIFEST'; export const UPDATE_CURSEFORGE_VERSION_IDS = 'UPDATE_CURSEFORGE_VERSION_IDS'; +export const UPDATE_MODRINTH_CATEGORIES = 'UPDATE_MODRINTH_CATEGORIES'; +export const UPDATE_MODRINTH_LOADERS = 'UPDATE_MODRINTH_LOADERS'; export const UPDATE_FABRIC_MANIFEST = 'UPDATE_FABRIC_MANIFEST'; export const UPDATE_JAVA_MANIFEST = 'UPDATE_JAVA_MANIFEST'; export const UPDATE_JAVA_LATEST_MANIFEST = 'UPDATE_JAVA_LATEST_MANIFEST'; diff --git a/src/common/reducers/actions.js b/src/common/reducers/actions.js index e8d51be55..97926e12b 100644 --- a/src/common/reducers/actions.js +++ b/src/common/reducers/actions.js @@ -27,6 +27,7 @@ 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 crypto from 'crypto'; import * as ActionTypes from './actionTypes'; import { ACCOUNT_MICROSOFT, @@ -43,11 +44,12 @@ import { MC_STARTUP_METHODS, MICROSOFT_OAUTH_CLIENT_ID, MICROSOFT_OAUTH_REDIRECT_URL, + MODRINTH, NEWS_URL } from '../utils/constants'; import { getAddon, - getAddonCategories, + getCurseForgeCategories, getAddonFile, getAddonFiles, getAddonsByFingerprint, @@ -59,6 +61,8 @@ import { getJavaLatestManifest, getJavaManifest, getMcManifest, + getModrinthVersionManifest, + getModrinthCategories, getMultipleAddons, mcAuthenticate, mcInvalidate, @@ -69,7 +73,9 @@ import { msAuthenticateXSTS, msExchangeCodeForAccessToken, msMinecraftProfile, - msOAuthRefresh + msOAuthRefresh, + getModrinthVersions, + getVersionsFromHashes } from '../api'; import { _getAccounts, @@ -116,12 +122,10 @@ import { downloadInstanceFiles } from '../../app/desktop/utils/downloader'; import { - addQuotes, getFileMurmurHash2, getSize, makeInstanceRestorePoint, - removeDuplicates, - replaceLibraryDirectory + removeDuplicates } from '../utils'; import { UPDATE_CONCURRENT_DOWNLOADS } from './settings/actionTypes'; import { UPDATE_MODAL } from './modals/actionTypes'; @@ -169,14 +173,24 @@ export function initManifests() { }); return java; }; - const getAddonCategoriesVersions = async () => { - const curseforgeCategories = await getAddonCategories(); + const getCurseForgeCategoriesVersions = async () => { + const curseforgeCategories = await getCurseForgeCategories(); dispatch({ type: ActionTypes.UPDATE_CURSEFORGE_CATEGORIES_MANIFEST, data: curseforgeCategories }); return curseforgeCategories; }; + const getModrinthCategoriesList = async () => { + const categories = await getModrinthCategories(); + + dispatch({ + type: ActionTypes.UPDATE_MODRINTH_CATEGORIES, + data: categories + }); + + return categories; + }; const getCurseForgeVersionIds = async () => { const versionIds = await getCFVersionIds(); const hm = {}; @@ -213,19 +227,41 @@ export function initManifests() { }); 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); + const [ + fabric, + java, + javaLatest, + curseForgeCategories, + modrinthCategories, + forge, + CFVersionIds + ] = await Promise.all([ + reflect(getFabricVersions()), + reflect(getJavaManifestVersions()), + reflect(getJavaLatestManifestVersions()), + reflect(getCurseForgeCategoriesVersions()), + reflect(getModrinthCategoriesList()), + reflect(getForgeVersions()), + reflect(getCurseForgeVersionIds()) + ]); + + if ( + fabric.e || + java.e || + curseForgeCategories.e || + modrinthCategories.e || + forge.e || + CFVersionIds.e + ) { + console.error( + fabric, + java, + curseForgeCategories, + modrinthCategories.e, + forge + ); } return { @@ -233,7 +269,12 @@ export function initManifests() { 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, + curseForgeCategories: curseForgeCategories.status + ? curseForgeCategories.v + : app.curseforgeCategories, + modrinthCategories: modrinthCategories.status + ? modrinthCategories.v + : app.modrinthCategories, forge: forge.status ? forge.v : app.forgeManifest, curseforgeVersionIds: CFVersionIds.status ? CFVersionIds.v @@ -1925,6 +1966,126 @@ export function processForgeManifest(instanceName) { }; } +export function processModrinthManifest(instanceName) { + return async (dispatch, getState) => { + // TODO: Scan for existing files and skip them if they are in the download list + + const state = getState(); + /** @type {{manifest: ModrinthManifest}} */ + const { manifest } = _getCurrentDownloadItem(state); + let { files } = manifest; + + const totalModsRequired = files.length; + + const instancesPath = _getInstancesPath(state); + const instancePath = path.join(instancesPath, instanceName); + + const concurrency = state.settings.concurrentDownloads; + + let prev = 0; + const updatePercentage = downloaded => { + const percentage = (downloaded * 100) / files.length; + const progress = parseInt(percentage, 10); + if (progress !== prev) { + prev = progress; + dispatch(updateDownloadProgress(progress)); + } + }; + + // TODO: If the download fails, we should attempt the next link in the downloads array + dispatch(updateDownloadStatus(instanceName, 'Downloading pack...')); + await downloadInstanceFiles( + files.map(file => { + return { + path: path.join(instancePath, file.path), + url: file.downloads.at(0), + sha1: file.hashes.sha1 + }; + }), + updatePercentage, + state.settings.concurrentDownloads + ); + + // verify that each mod downloaded correctly + files = files.filter( + async file => { + const filePath = path.join(instancePath, file.path); + const buf = await fs.readFile(filePath); + const sha1 = crypto.createHash('sha1').update(buf).digest('hex'); + const sha512 = crypto.createHash('sha512').update(buf).digest('hex'); + + if (sha1 === file.hashes.sha1 && sha512 === file.hashes.sha512) { + return file; + } + + console.error( + `Mod "${file.filename}" failed to download: hashes did not match` + ); + // TODO: Attempt to re-download here? + return null; + }, + { concurrency } + ); + + if (files.length !== totalModsRequired) { + // the number of valid mods we have does not match the expected amount + // this means the download has failed and should be restarted + // ideally this should be done on a per-mod basis + + throw Error( + `One or more mods failed to download (expected ${totalModsRequired}, but got ${files.length})` + ); + } + + dispatch(updateDownloadStatus(instanceName, 'Finalizing files...')); + + const hashVersionMap = await getVersionsFromHashes( + files.map(file => file.hashes.sha512), + 'sha512' + ); + + let modManifests = []; + await pMap( + files, + async file => { + /** @type {ModrinthVersion} */ + const version = hashVersionMap[file.hashes.sha512]; + + // TODO: Remember which file was actually downloaded and put it here instead of just using the first one + const fileName = path.basename(file.path); + modManifests = [ + ...modManifests, + { + projectID: version?.project_id ?? null, + fileID: version?.id ?? null, + fileName, + displayName: fileName, + version: version?.version_number ?? null, + downloadUrl: file.downloads.at(0), + modSource: MODRINTH + } + ]; + + const percentage = (modManifests.length * 100) / files.length; + + dispatch(updateDownloadProgress(percentage > 0 ? percentage : 0)); + }, + { concurrency } + ); + + await dispatch( + updateInstanceConfig(instanceName, config => { + return { + ...config, + mods: [...(config.mods || []), ...modManifests] + }; + }) + ); + + await fse.remove(path.join(_getTempPath(state), instanceName)); + }; +} + export function downloadInstance(instanceName) { return async (dispatch, getState) => { const state = getState(); @@ -2095,18 +2256,33 @@ export function downloadInstance(instanceName) { 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 and do it for ftb and forge - - if (manifest && loader?.source === FTB) - await dispatch(processFTBManifest(instanceName)); - else if (manifest && loader?.source === CURSEFORGE) - await dispatch(processForgeManifest(instanceName)); + // analyze source and do it for ftb, curseforge, and modrinth + if (manifest) { + switch (loader?.source) { + case FTB: { + await dispatch(processFTBManifest(instanceName)); + break; + } + case CURSEFORGE: { + await dispatch(processForgeManifest(instanceName)); + break; + } + case MODRINTH: { + await dispatch(processModrinthManifest(instanceName)); + break; + } + default: { + console.error(`Unknown modpack source: ${loader?.source}`); + } + } + } dispatch(updateDownloadProgress(0)); @@ -2138,149 +2314,217 @@ export const changeModpackVersion = (instanceName, newModpackData) => { const tempPath = _getTempPath(state); const instancePath = path.join(_getInstancesPath(state), instanceName); - if (instance.loader.source === CURSEFORGE) { - const addon = await getAddon(instance.loader?.projectID); + switch (instance.loader.source) { + case CURSEFORGE: { + const addon = await getAddon(instance.loader?.projectID); - const manifest = await fse.readJson( - path.join(instancePath, 'manifest.json') - ); + const manifest = await fse.readJson( + path.join(instancePath, 'manifest.json') + ); - await fse.remove(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 - } - }) - ); + // 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); + const modsprojectIds = (manifest?.files || []).map(v => v?.projectID); - dispatch( - updateInstanceConfig(instanceName, prev => - omit( - { - ...prev, - mods: prev.mods.filter( - v => !modsprojectIds.includes(v?.projectID) - ) - }, - ['overrides'] + 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 + 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 imageURL = addon?.logo?.thumbnailUrl; - const newManifest = await downloadAddonZip( - instance.loader?.projectID, - newModpackData.id, - path.join(_getInstancesPath(state), instanceName), - path.join(tempPath, instanceName) - ); + 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 - ); + 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 + 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 } + ) ); + + break; } + case FTB: { + const imageURL = newModpackData.imageUrl; - const loader = { - loaderType: instance.loader?.loaderType, - mcVersion: newManifest.minecraft.version, - loaderVersion, - fileID: instance.loader?.fileID, - projectID: instance.loader?.projectID, - source: instance.loader?.source - }; + await downloadFile( + path.join( + _getInstancesPath(state), + instanceName, + `background${path.extname(imageURL)}` + ), + imageURL + ); - dispatch( - addToQueue( - instanceName, - loader, - newManifest, - `background${path.extname(imageURL)}`, - undefined, - undefined, - { isUpdate: true, bypassCopy: true } - ) - ); - } else if (instance.loader.source === FTB) { - const imageURL = newModpackData.imageUrl; + const newModpack = await getFTBModpackVersionData( + instance.loader?.projectID, + newModpackData.id + ); - await downloadFile( - path.join( - _getInstancesPath(state), - instanceName, - `background${path.extname(imageURL)}` - ), - imageURL - ); + const loader = { + loaderType: instance.loader?.loaderType, - const newModpack = await getFTBModpackVersionData( - instance.loader?.projectID, - newModpackData.id - ); + mcVersion: newModpack.targets[1].version, + loaderVersion: convertcurseForgeToCanonical( + `forge-${newModpack.targets[0].version}`, + newModpack.targets[1].version, + state.app.forgeManifest + ), + fileID: newModpack?.id, + projectID: instance.loader?.projectID, + source: instance.loader?.source + }; - const loader = { - loaderType: instance.loader?.loaderType, + dispatch( + addToQueue( + instanceName, + loader, + null, + `background${path.extname(imageURL)}` + ) + ); - mcVersion: newModpack.targets[1].version, - loaderVersion: convertcurseForgeToCanonical( - `forge-${newModpack.targets[0].version}`, - newModpack.targets[1].version, - state.app.forgeManifest - ), - fileID: newModpack?.id, - projectID: instance.loader?.projectID, - source: instance.loader?.source - }; + break; + } + case MODRINTH: { + const manifest = await getModrinthVersionManifest( + newModpackData?.id, + path.join(_getInstancesPath(state), instanceName) + ); + const imageURL = newModpackData.imageUrl; + const loaderType = instance.loader?.loaderType; + let loaderDependencyName; + switch (loaderType) { + case FORGE: { + loaderDependencyName = 'forge'; + break; + } + case FABRIC: { + loaderDependencyName = 'fabric-loader'; + break; + } + // case QUILT: { + // loaderDependencyName = 'quilt-loader'; + // break; + // } + default: + throw Error( + `This instance (${instanceName}) requires an unsupported loader: ${loaderType}` + ); + } - dispatch( - addToQueue( - instanceName, - loader, - null, - `background${path.extname(imageURL)}` - ) - ); + const loader = { + loaderType, + mcVersion: newModpackData?.gameVersions.at(0), + loaderVersion: manifest.dependencies[loaderDependencyName], + fileID: newModpackData?.id, + projectID: instance.loader?.projectID, + source: instance.loader?.source + }; + + await downloadFile( + path.join( + _getInstancesPath(state), + instanceName, + `background${path.extname(imageURL)}` + ), + imageURL + ); + + dispatch( + addToQueue( + instanceName, + loader, + manifest, + `background${path.extname(imageURL)}` + ) + ); + + break; + } + default: { + console.error(`Unknown modpack source: ${instance.loader.source}`); + } } }; }; @@ -2968,18 +3212,20 @@ export function launchInstance(instanceName, forceQuit = false) { 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' ? '";' : '":' - ); + return arg + .replace(/\${version_name}/g, mcJson.id) + .replace( + /=\${library_directory}/g, + `="${_getLibrariesPath(state)}"` + ) + .replace( + /\${library_directory}/g, + `${_getLibrariesPath(state)}` + ) + .replace( + /\${classpath_separator}/g, + process.platform === 'win32' ? ';' : ':' + ); } ); } @@ -3082,10 +3328,8 @@ export function launchInstance(instanceName, forceQuit = false) { loggingId || '' ); - const needsQuote = process.platform !== 'win32'; - console.log( - `${addQuotes(needsQuote, javaPath)} ${getJvmArguments( + `"${javaPath}" ${getJvmArguments( libraries, mcMainFile, instancePath, @@ -3101,7 +3345,7 @@ export function launchInstance(instanceName, forceQuit = false) { .replace( // eslint-disable-next-line no-template-curly-in-string '-Dlog4j.configurationFile=${path}', - `-Dlog4j.configurationFile=${addQuotes(needsQuote, loggingPath)}` + `-Dlog4j.configurationFile="${loggingPath}"` ) ); @@ -3112,7 +3356,7 @@ export function launchInstance(instanceName, forceQuit = false) { let closed = false; const ps = spawn( - `${addQuotes(needsQuote, javaPath)}`, + `"${javaPath}"`, jvmArguments.map(v => v .toString() @@ -3120,12 +3364,12 @@ export function launchInstance(instanceName, forceQuit = false) { .replace( // eslint-disable-next-line no-template-curly-in-string '-Dlog4j.configurationFile=${path}', - `-Dlog4j.configurationFile=${addQuotes(needsQuote, loggingPath)}` + `-Dlog4j.configurationFile="${loggingPath}"` ) ), { cwd: instancePath, - shell: process.platform !== 'win32' + shell: true } ); @@ -3394,6 +3638,96 @@ export function installMod( }; } +/** + * @param {ModrinthVersion} version + * @param {string} instanceName + * @param {Function} onProgress + */ +export function installModrinthMod(version, instanceName, onProgress) { + return async (dispatch, getState) => { + const state = getState(); + const instancesPath = _getInstancesPath(state); + const instancePath = path.join(instancesPath, instanceName); + + // Get mods that are already installed so we can skip them + let existingMods = []; + await dispatch( + updateInstanceConfig(instanceName, config => { + existingMods = config.mods; + return config; + }) + ); + + const dependencies = (await resolveModrinthDependencies(version)).filter( + dep => existingMods.find(mod => mod.fileID === dep.id) === undefined + ); + + // install dependencies and the mod that we want + await pMap( + [...dependencies, version], + async v => { + const primaryFile = v.files.find(f => f.primary); + + const destFile = path.join(instancePath, 'mods', primaryFile.filename); + const tempFile = path.join(_getTempPath(state), primaryFile.filename); + + // download the mod + await downloadFile(tempFile, primaryFile.url, onProgress); + + // add mod to the mods list in the instance's config file + await dispatch( + updateInstanceConfig(instanceName, config => { + return { + ...config, + mods: [ + ...config.mods, + ...[ + { + source: MODRINTH, + projectID: v.project_id, + fileID: v.id, + fileName: primaryFile.filename, + displayName: primaryFile.filename, + downloadUrl: primaryFile.url + } + ] + ] + }; + }) + ); + + await fse.move(tempFile, destFile, { overwrite: true }); + }, + { concurrency: 2 } + ); + }; +} + +/** + * Recursively gets all the dependent versions of a given version and returns them in one array + * @param {ModrinthVersion} version + * @returns {Promise} + */ +async function resolveModrinthDependencies(version) { + // TODO: Ideally this function should be aware of mods the user already has installed and ignore them + + // Get the IDs for this version's required dependencies + const depVersionIDs = version.dependencies + .filter(v => v.dependency_type === 'required') + .map(v => v.version_id); + + // If this version does not depend on anything, return nothing + if (depVersionIDs.length === 0) return []; + + // If we do have dependencies, get the version objects for each of those and recurse on those + const depVersions = await getModrinthVersions(depVersionIDs); + const subDepVersions = await pMap(depVersions, async v => + resolveModrinthDependencies(v) + ); + + return [...depVersions, ...subDepVersions]; +} + export const deleteMod = (instanceName, mod) => { return async (dispatch, getState) => { const instancesPath = _getInstancesPath(getState()); diff --git a/src/common/reducers/app.js b/src/common/reducers/app.js index 668ade313..aa65f538e 100644 --- a/src/common/reducers/app.js +++ b/src/common/reducers/app.js @@ -74,6 +74,15 @@ function curseforgeVersionIds(state = {}, action) { } } +function modrinthCategories(state = [], action) { + switch (action.type) { + case ActionTypes.UPDATE_MODRINTH_CATEGORIES: + return action.data; + default: + return state; + } +} + function javaManifest(state = {}, action) { switch (action.type) { case ActionTypes.UPDATE_JAVA_MANIFEST: @@ -131,5 +140,6 @@ export default combineReducers({ clientToken, isNewUser, lastUpdateVersion, - curseforgeVersionIds + curseforgeVersionIds, + modrinthCategories }); diff --git a/src/common/utils/constants.js b/src/common/utils/constants.js index 393ca5cef..1a178da5c 100644 --- a/src/common/utils/constants.js +++ b/src/common/utils/constants.js @@ -14,6 +14,7 @@ export const MC_LIBRARIES_URL = 'https://libraries.minecraft.net'; export const FORGESVC_URL = 'https://api.curseforge.com/v1'; export const FTB_API_URL = 'https://api.modpacks.ch/public'; export const FTB_MODPACK_URL = 'https://feed-the-beast.com/modpack'; +export const MODRINTH_API_URL = 'https://api.modrinth.com/v2'; export const NEWS_URL = 'https://www.minecraft.net/en-us/feeds/community-content/rss'; export const FMLLIBS_OUR_BASE_URL = 'https://fmllibs.gdevs.io'; @@ -26,6 +27,7 @@ export const VANILLA = 'vanilla'; export const CURSEFORGE = 'curseforge'; export const FTB = 'ftb'; +export const MODRINTH = 'modrinth'; export const ACCOUNT_MOJANG = 'ACCOUNT_MOJANG'; export const ACCOUNT_MICROSOFT = 'ACCOUNT_MICROSOFT'; diff --git a/src/common/utils/index.js b/src/common/utils/index.js index 68f088f97..5c224241f 100644 --- a/src/common/utils/index.js +++ b/src/common/utils/index.js @@ -285,22 +285,3 @@ export const getSize = async dir => { .catch(e => reject(e)); }); }; - -export const addQuotes = (needsQuote, string) => { - return needsQuote ? `"${string}"` : string; -}; - -export const replaceLibraryDirectory = (arg, librariesDir) => { - const parsedArg = arg.replace(/\${library_directory}/g, `"${librariesDir}`); - const regex = /\${classpath_separator}/g; - const isLibrariesArgString = arg.match(regex); - const splittedString = parsedArg.split(regex); - splittedString[splittedString.length - 1] = `${ - splittedString[splittedString.length - 1] - }"`; - - return isLibrariesArgString - ? // eslint-disable-next-line no-template-curly-in-string - splittedString.join('${classpath_separator}') - : arg; -}; diff --git a/src/types/modrinth.js b/src/types/modrinth.js new file mode 100644 index 000000000..5d48e3619 --- /dev/null +++ b/src/types/modrinth.js @@ -0,0 +1,147 @@ +// Modrinth type data from https://docs.modrinth.com/api-spec/ + +/** + * @typedef {Object} ModrinthProject Projects can be mods or modpacks and are created by users. + * @property {string} slug The slug of a project, used for vanity URLs + * @property {string} title The title or name of the project + * @property {string} description A short description of the project + * @property {string[]} categories A list of the categories that the project is in + * @property {ModrinthEnvironment} client_side The client side support of the project + * @property {ModrinthEnvironment} server_side The server side support of the project + * @property {string} body A long form description of the project + * @property {?string} issues_url An optional link to where to submit bugs or issues with the project + * @property {?string} source_url An optional link to the source code of the project + * @property {?string} wiki_url An optional link to the project's wiki page or other relevant information + * @property {?string} discord_url An optional invite link to the project's discord + * @property {DonationURL[]} donation_urls A list of donation links for the project + * @property {'mod'|'modpack'} project_type The project type of the project + * @property {number} downloads The total number of downloads of the project + * @property {?string} icon_url The URL of the project's icon + * @property {string} id The ID of the project, encoded as a base62 string + * @property {string} team The ID of the team that has ownership of this project + * @property {?string} body_url DEPRECATED - The link to the long description of the project (only present for old projects) + * @property {ModrinthModeratorMessage|null} moderator_message A message that a moderator sent regarding the project + * @property {string} published The date the project was published + * @property {string} updated The date the project was last updated + * @property {number} followers The total number of users following the project + * @property {'approved'|'rejected'|'draft'|'unlisted'|'archived'|'processing'|'unknown'} status The status of the project + * @property {ModrinthLicense?} license The license of the project + * @property {string[]} versions A list of the version IDs of the project (will never be empty unless `draft` status) + * @property {ModrinthGalleryImage[]} gallery A list of images that have been uploaded to the project's gallery + */ + +/** + * @typedef {Object} ModrinthVersion Versions contain download links to files with additional metadata. + * @property {string} name The name of this version + * @property {string} version_number The version number. Ideally will follow semantic versioning + * @property {?string} changelog The changelog for this version + * @property {ModrinthDependency[]} dependencies A list of specific versions of projects that this version depends on + * @property {string[]} game_versions A list of versions of Minecraft that this version supports + * @property {'release'|'beta'|'alpha'} version_type The release channel for this version + * @property {string[]} loaders The mod loaders that this version supports + * @property {boolean} featured Whether the version is featured or not + * @property {string} id The ID of the version, encoded as a base62 string + * @property {string} project_id The ID of the project this version is for + * @property {string} author_id The ID of the author who published this version + * @property {string} date_published The date the version was published + * @property {number} downloads The number of times this version has been downloaded + * @property {string} changelog_url DEPRECATED - A link to the changelog for this version + * @property {ModrinthFile[]} files A list of files available for download for this version + */ + +/** + * @typedef {Object} ModrinthDependency + * @property {?string} version_id The ID of the version that this version depends on + * @property {?string} project_id The ID of the project that this version depends on + * @property {'required'|'optional'|'incompatible'} dependency_type The type of dependency that this version has + */ + +/** + * @typedef {Object} ModrinthFile + * @property {{ sha1: string, sha512: string }} hashes The hashes of the file + * @property {string} url A direct link to the file + * @property {string} filename The name of the file + * @property {boolean} primary + * @property {number} size The size of the file in bytes + */ + +/** + * @typedef {Object} ModrinthSearchResult + * @property {?string} slug The slug of a project, used for vanity URLs + * @property {?string} title The title or name of the project + * @property {?string} description A short description of the project + * @property {string[]} categories A list of the categories that the project is in + * @property {ModrinthEnvironment} client_side The client side support of the project + * @property {ModrinthEnvironment} server_side The server side support of the project + * @property {'mod'|'modpack'} project_type The project type of the project + * @property {number} downloads The total number of downloads of the project + * @property {?string} icon_url The URL of the project's icon + * @property {string} project_id The ID of the project + * @property {string} author The username of the project's author + * @property {string[]} versions A list of the minecraft versions supported by the project + * @property {number} follows The total number of users following the project + * @property {string} date_created The date the project was created + * @property {string} date_modified The date the project was last modified + * @property {?string} latest_version The latest version of minecraft that this project supports + * @property {string} license The license of the project + * @property {string[]} gallery All gallery images attached to the project + */ + +/** + * @typedef ModrinthUser + * @property {string} username The user's username + * @property {?string} name The user's display name + * @property {?string} email The user's email (only your own is ever displayed) + * @property {?string} bio A description of the user + * @property {string} id The user's id + * @property {number} github_id The user's github id + * @property {string} avatar_url The user's avatar url + * @property {string} created The time at which the user was created + * @property {'admin'|'moderator'|'developer'} role The user's role + */ + +/** + * @typedef ModrinthTeamMember + * @property {string} team_id The ID of the team this team member is a member of + * @property {ModrinthUser} user + * @property {string} role The user's role on the team + */ + +/** + * @typedef ModrinthCategory + * @property {string} icon + * @property {string} name + * @property {string} project_type + */ + +/** + * @typedef ModrinthManifest + * @property {number} formatVersion The version of the format + * @property {string} game The game of the modpack + * @property {string} versionId A unique identifier for this specific version of the modpack + * @property {string} name Human-readable name of the modpack. + * @property {?string} summary A short description of this modpack + * @property {ModrinthManifestFile[]} files The files array contains a list of files for the modpack that needs to be downloaded + * @property {ModrinthManifestDependencies} dependencies This object contains a list of IDs and version numbers that launchers will use in order to know what to install + */ + +/** + * @typedef ModrinthManifestFile + * @property {string} path The destination path of this file, relative to the Minecraft instance directory. For example, mods/MyMod.jar resolves to .minecraft/mods/MyMod.jar + * @property {{sha1: string, sha512: string}} hashes The hashes of the file specified + * @property {{client: ModrinthEnvironment, server: ModrinthEnvironment}} env For files that only exist on a specific environment, this field allows that to be specified. It's an object which contains a client and server value. This uses the Modrinth client/server type specifications. + * @property {string[]} downloads An array containing HTTPS URLs where this file may be downloaded + * @property {number} fileSize An integer containing the size of the file, in bytes. This is mostly provided as a utility for launchers to allow use of progress bars. + */ + +/** + * @typedef ModrinthManifestDependencies + * @property {?string} minecraft The Minecraft game + * @property {?string} forge The Minecraft Forge mod loader + * @property {?string} fabric-loader The Fabric loader + * @property {?string} quilt-loader The Quilt loader + */ + +/** + * @typedef {'required'|'optional'|'unsupported'} ModrinthEnvironment + */ From 9ec93189529b7ef29e68b5117734b2892f2f0f03 Mon Sep 17 00:00:00 2001 From: Pyroglyph Date: Mon, 11 Jul 2022 16:02:14 +0100 Subject: [PATCH 2/8] Add proper User-Agent for Modrinth API requests --- src/common/api.js | 46 +++++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/src/common/api.js b/src/common/api.js index f57564e5f..8e0341d16 100644 --- a/src/common/api.js +++ b/src/common/api.js @@ -4,6 +4,7 @@ import qs from 'querystring'; import path from 'path'; import fse from 'fs-extra'; import os from 'os'; +import { version as appVersion } from '../../package.json'; import { MOJANG_APIS, FORGESVC_URL, @@ -33,6 +34,13 @@ const axioInstance = axios.create({ } }); +const modrinthClient = axios.create({ + baseURL: MODRINTH_API_URL, + headers: { + 'User-Agent': `gorilla-devs/GDLauncher/${appVersion}` + } +}); + const trackFTBAPI = () => { ga.sendCustomEvent('FTBAPICall'); }; @@ -428,8 +436,8 @@ export const getFTBSearch = async searchText => { */ export const getModrinthMostPlayedModpacks = async (offset = 0) => { trackModrinthAPI(); - const url = `${MODRINTH_API_URL}/search?limit=20&offset=${offset}&index=downloads&facets=[["project_type:modpack"]]`; - const { data } = await axios.get(url); + const url = `/search?limit=20&offset=${offset}&index=downloads&facets=[["project_type:modpack"]]`; + const { data } = await modrinthClient.get(url); return data; }; @@ -468,7 +476,7 @@ export const getModrinthSearchResults = async ( facets.push(...filteredCategories.map(cat => [`categories:${cat}`])); } - const { data } = await axios.get(`${MODRINTH_API_URL}/search`, { + const { data } = await modrinthClient.get(`/search`, { params: { limit: 20, query: query ?? undefined, @@ -496,10 +504,8 @@ export const getModrinthProject = async projectId => { export const getModrinthProjects = async projectIds => { trackModrinthAPI(); try { - const url = `${MODRINTH_API_URL}/projects?ids=${JSON.stringify( - projectIds - )}`; - const { data } = await axios.get(url); + const url = `/projects?ids=${JSON.stringify(projectIds)}`; + const { data } = await modrinthClient.get(url); return data.map(fixModrinthProjectObject); } catch { return { status: 'error' }; @@ -513,8 +519,8 @@ export const getModrinthProjects = async projectIds => { export const getModrinthProjectVersions = async projectId => { trackModrinthAPI(); try { - const url = `${MODRINTH_API_URL}/project/${projectId}/version`; - const { data } = await axios.get(url); + const url = `/project/${projectId}/version`; + const { data } = await modrinthClient.get(url); return data; } catch { return { status: 'error' }; @@ -536,10 +542,8 @@ export const getModrinthVersion = async versionId => { export const getModrinthVersions = async versionIds => { trackModrinthAPI(); try { - const url = `${MODRINTH_API_URL}/versions?ids=${JSON.stringify( - versionIds - )}`; - const { data } = await axios.get(url); + const url = `versions?ids=${JSON.stringify(versionIds)}`; + const { data } = await modrinthClient.get(url); return data || []; } catch (err) { console.error(err); @@ -615,8 +619,8 @@ export const getModrinthVersionChangelog = async versionId => { export const getModrinthUser = async userId => { trackModrinthAPI(); try { - const url = `${MODRINTH_API_URL}/user/${userId}`; - const { data } = await axios.get(url); + const url = `/user/${userId}`; + const { data } = modrinthClient.get(url); return data; } catch (err) { console.error(err); @@ -637,8 +641,8 @@ const fixModrinthProjectObject = project => { export const getModrinthCategories = async () => { trackModrinthAPI(); try { - const url = `${MODRINTH_API_URL}/tag/category`; - const { data } = await axios.get(url); + const url = '/tag/category'; + const { data } = await modrinthClient.get(url); return data; } catch (err) { console.error(err); @@ -652,8 +656,8 @@ export const getModrinthCategories = async () => { export const getModrinthProjectMembers = async projectId => { trackModrinthAPI(); try { - const url = `${MODRINTH_API_URL}/project/${projectId}/members`; - const { data } = await axios.get(url); + const url = `/project/${projectId}/members`; + const { data } = await modrinthClient.get(url); return data; } catch (err) { console.error(err); @@ -668,8 +672,8 @@ export const getModrinthProjectMembers = async projectId => { export const getVersionsFromHashes = async (hashes, algorithm) => { trackModrinthAPI(); try { - const url = `${MODRINTH_API_URL}/version_files`; - const { data } = await axios.post(url, { hashes, algorithm }); + const url = '/version_files'; + const { data } = await modrinthClient.post(url, { hashes, algorithm }); return data; } catch (err) { console.error(err); From 2e3d6ae7e53ef46578bd754dd3ea6f1deb546db8 Mon Sep 17 00:00:00 2001 From: Pyroglyph Date: Mon, 11 Jul 2022 16:54:26 +0100 Subject: [PATCH 3/8] Fall back to other mod download links if one fails --- src/app/desktop/utils/downloader.js | 105 ++++++++++++++++++++++++++++ src/common/reducers/actions.js | 53 ++------------ 2 files changed, 111 insertions(+), 47 deletions(-) diff --git a/src/app/desktop/utils/downloader.js b/src/app/desktop/utils/downloader.js index 043d65a5f..834fe7517 100644 --- a/src/app/desktop/utils/downloader.js +++ b/src/app/desktop/utils/downloader.js @@ -118,6 +118,111 @@ const downloadFileInstance = async (fileName, url, sha1, legacyPath) => { } }; +/** + * @param {{ path: string, hashes: { sha1: string, sha512: string }, downloads: string[] }[]} files + * @param {string} instancePath + * @param {number} updatePercentage + * @param {number} threads + */ +export const downloadInstanceFilesWithFallbacks = async ( + files, + instancePath, + updatePercentage, + threads = 4 +) => { + let downloaded = 0; + await pMap( + files, + async file => { + let counter = 0; + let res = false; + do { + counter += 1; + if (counter !== 1) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + + try { + res = await downloadFileInstanceWithFallbacks(file, instancePath); + } catch { + // Do nothing + } + } while (!res && counter < 3); + downloaded += 1; + if ( + updatePercentage && + (downloaded % 5 === 0 || downloaded === files.length) + ) { + updatePercentage(downloaded); + } + }, + { concurrency: threads } + ); +}; + +/** + * @param {{ path: string, hashes: { [algo: string]: string }, downloads: string[] }[]} file + * @param {string} instancePath + */ +const downloadFileInstanceWithFallbacks = async (file, instancePath) => { + const filePath = path.join(instancePath, file.path); + const dirPath = path.dirname(filePath); + try { + await fs.access(filePath); + + let allChecksumsMatch = false; + for (const algo of Object.keys(file.hashes)) { + const checksum = await computeFileHash(filePath, algo); + if (file.hashes[algo] === checksum) { + allChecksumsMatch = true; + } + } + if (allChecksumsMatch) { + // the file already exists on disk, skip it + return true; + } + } catch { + await makeDir(dirPath); + } + + // this loop exits as soon as a download has been successful + for (const url of file.downloads) { + const encodedUrl = getUri(url); + try { + const { data } = await axios.get(encodedUrl, { + responseType: 'stream', + responseEncoding: null, + adapter, + timeout: 60000 * 20 + }); + + const wStream = fss.createWriteStream(filePath, { + encoding: null + }); + + data.pipe(wStream); + + await new Promise((resolve, reject) => { + data.on('error', err => { + console.error(err); + reject(err); + }); + + data.on('end', () => { + wStream.end(); + resolve(); + }); + }); + + return true; + } catch (e) { + console.error( + `Error while downloading <${url} | ${encodedUrl}> to <${file.path}> --> ${e.message}` + ); + } + } +}; + export const downloadFile = async (fileName, url, onProgress) => { await makeDir(path.dirname(fileName)); diff --git a/src/common/reducers/actions.js b/src/common/reducers/actions.js index 97926e12b..75461367a 100644 --- a/src/common/reducers/actions.js +++ b/src/common/reducers/actions.js @@ -27,7 +27,6 @@ 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 crypto from 'crypto'; import * as ActionTypes from './actionTypes'; import { ACCOUNT_MICROSOFT, @@ -119,7 +118,8 @@ import { import ga from '../utils/analytics'; import { downloadFile, - downloadInstanceFiles + downloadInstanceFiles, + downloadInstanceFilesWithFallbacks } from '../../app/desktop/utils/downloader'; import { getFileMurmurHash2, @@ -1968,14 +1968,10 @@ export function processForgeManifest(instanceName) { export function processModrinthManifest(instanceName) { return async (dispatch, getState) => { - // TODO: Scan for existing files and skip them if they are in the download list - const state = getState(); /** @type {{manifest: ModrinthManifest}} */ const { manifest } = _getCurrentDownloadItem(state); - let { files } = manifest; - - const totalModsRequired = files.length; + const { files } = manifest; const instancesPath = _getInstancesPath(state); const instancePath = path.join(instancesPath, instanceName); @@ -1992,51 +1988,14 @@ export function processModrinthManifest(instanceName) { } }; - // TODO: If the download fails, we should attempt the next link in the downloads array dispatch(updateDownloadStatus(instanceName, 'Downloading pack...')); - await downloadInstanceFiles( - files.map(file => { - return { - path: path.join(instancePath, file.path), - url: file.downloads.at(0), - sha1: file.hashes.sha1 - }; - }), + await downloadInstanceFilesWithFallbacks( + files, + instancePath, updatePercentage, state.settings.concurrentDownloads ); - // verify that each mod downloaded correctly - files = files.filter( - async file => { - const filePath = path.join(instancePath, file.path); - const buf = await fs.readFile(filePath); - const sha1 = crypto.createHash('sha1').update(buf).digest('hex'); - const sha512 = crypto.createHash('sha512').update(buf).digest('hex'); - - if (sha1 === file.hashes.sha1 && sha512 === file.hashes.sha512) { - return file; - } - - console.error( - `Mod "${file.filename}" failed to download: hashes did not match` - ); - // TODO: Attempt to re-download here? - return null; - }, - { concurrency } - ); - - if (files.length !== totalModsRequired) { - // the number of valid mods we have does not match the expected amount - // this means the download has failed and should be restarted - // ideally this should be done on a per-mod basis - - throw Error( - `One or more mods failed to download (expected ${totalModsRequired}, but got ${files.length})` - ); - } - dispatch(updateDownloadStatus(instanceName, 'Finalizing files...')); const hashVersionMap = await getVersionsFromHashes( From e9d5e1f37c9127db01d119c084fb3c8d694185f0 Mon Sep 17 00:00:00 2001 From: Pyroglyph Date: Mon, 11 Jul 2022 17:12:21 +0100 Subject: [PATCH 4/8] Remove unnecessary API call --- src/common/modals/AddInstance/InstanceName.js | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/common/modals/AddInstance/InstanceName.js b/src/common/modals/AddInstance/InstanceName.js index 4adb46570..110402da9 100644 --- a/src/common/modals/AddInstance/InstanceName.js +++ b/src/common/modals/AddInstance/InstanceName.js @@ -311,35 +311,29 @@ const InstanceName = ({ ) ); } else if (isModrinthModpack) { - //! manifest.dependencies and fullVersion.dependencies are different things! - // manifest.dependencies contains only the game version and loader version (referred to here as mainDependencies) - // fullVersion.dependencies contains objects with mod ids - - const fullVersion = await getModrinthVersion(version?.fileID); - const manifest = await getModrinthVersionManifest( version?.fileID, path.join(instancesPath, localInstanceName) ); const mcVersion = manifest.dependencies.minecraft; - const mainDependencies = Object.keys(manifest.dependencies); + const dependencies = Object.keys(manifest.dependencies); let loaderType; let loaderVersion; - if (mainDependencies.includes('fabric-loader')) { + if (dependencies.includes('fabric-loader')) { loaderType = FABRIC; loaderVersion = manifest.dependencies['fabric-loader']; - } else if (mainDependencies.includes('forge')) { + } else if (dependencies.includes('forge')) { loaderType = FORGE; loaderVersion = convertcurseForgeToCanonical( manifest.dependencies['forge'], mcVersion, forgeManifest ); - } else if (mainDependencies.includes('quilt-loader')) { + } else if (dependencies.includes('quilt-loader')) { // we don't support Quilt yet, so we can't proceed with the installation dispatch(closeModal()); - throw 'Quilt modpacks are not yet supported.'; + throw Error('Quilt modpacks are not yet supported.'); // loaderType = QUILT; // loaderVersion = manifest.dependencies['quilt-loader']; From 4c3a9fe9bb1c2c4df88f8a852ab42e801a580887 Mon Sep 17 00:00:00 2001 From: Pyroglyph Date: Thu, 21 Jul 2022 16:35:51 +0100 Subject: [PATCH 5/8] Fix individual Modrinth mods not installing --- src/common/modals/ModOverview.js | 64 +++++++++++++++---------- src/common/modals/ModpackDescription.js | 2 +- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/src/common/modals/ModOverview.js b/src/common/modals/ModOverview.js index df3e8a906..339632ef9 100644 --- a/src/common/modals/ModOverview.js +++ b/src/common/modals/ModOverview.js @@ -16,11 +16,16 @@ import { getAddon, getModrinthProject, getModrinthVersions, - getModrinthUser + getModrinthUser, + getModrinthVersion } from '../api'; import CloseButton from '../components/CloseButton'; import { closeModal, openModal } from '../reducers/modals/actions'; -import { installMod, updateInstanceConfig } from '../reducers/actions'; +import { + installMod, + installModrinthMod, + updateInstanceConfig +} from '../reducers/actions'; import { remove } from 'fs-extra'; import { _getInstancesPath, _getInstance } from '../utils/selectors'; import { @@ -109,15 +114,13 @@ const ModOverview = ({ setMod(project); setDescription(project.body); - const versions = ( - await getModrinthVersions(project.versions) - ).sort( + const versions = (await getModrinthVersions(project.versions)).sort( (a, b) => Date.parse(b.date_published) - Date.parse(a.date_published) ); setFiles(versions); setLoadingFiles(false); getModrinthUser(versions[0].author_id).then(user => { - setAuthor(user.username); + setAuthor(user?.username || ''); }); setDownloadCount(project.downloads); setUpdatedDate(Date.parse(project.updated)); @@ -189,7 +192,13 @@ const ModOverview = ({ } }; - const handleChange = value => setSelectedItem(JSON.parse(value)); + const handleChange = value => { + if (modSource === CURSEFORGE) { + setSelectedItem(JSON.parse(value)); + } else if (modSource === MODRINTH) { + setSelectedItem(value); + } + }; const primaryImage = addon?.logo || mod?.icon_url; return ( @@ -299,9 +308,7 @@ const ModOverview = ({ loading={loadingFiles} disabled={loadingFiles} value={ - files.length !== 0 && - files.find(v => v.id === installedData.fileID) && - selectedItem + files.find(v => v.id === installedData.fileID) && selectedItem } onChange={handleChange} listItemHeight={50} @@ -376,7 +383,7 @@ const ModOverview = ({ onClick={async () => { setLoading(true); if (installedData.fileID) { - await dispatch( + dispatch( updateInstanceConfig(instanceName, prev => ({ ...prev, mods: prev.mods.filter( @@ -393,19 +400,28 @@ const ModOverview = ({ ) ); } - const newFile = await dispatch( - installMod( - projectID, - selectedItem, - instanceName, - gameVersions, - !installedData.fileID, - null, - null, - addon - ) - ); - setInstalledData({ fileID: selectedItem, fileName: newFile }); + if (modSource === CURSEFORGE) { + const newFile = dispatch( + installMod( + projectID, + selectedItem, + instanceName, + gameVersions, + !installedData.fileID, + null, + null, + addon + ) + ); + setInstalledData({ fileID: selectedItem, fileName: newFile }); + } else if (modSource === MODRINTH) { + const version = await getModrinthVersion(selectedItem); + const newFile = dispatch( + installModrinthMod(version, instanceName) + ); + setInstalledData({ fileID: selectedItem, fileName: newFile }); + } + setLoading(false); }} > diff --git a/src/common/modals/ModpackDescription.js b/src/common/modals/ModpackDescription.js index ba9c4532e..8ee235b82 100644 --- a/src/common/modals/ModpackDescription.js +++ b/src/common/modals/ModpackDescription.js @@ -97,7 +97,7 @@ const ModpackDescription = ({ ); setFiles(versions); getModrinthUser(versions[0].author_id).then(user => { - setAuthor(user.username); + setAuthor(user?.username || ''); }); setDownloadCount(modpack.downloads); setUpdatedDate(Date.parse(modpack.updated)); From 7c3f835d02eb0ccfbe661cc1b83ca1765bdbe9ee Mon Sep 17 00:00:00 2001 From: Pyroglyph Date: Thu, 21 Jul 2022 16:51:07 +0100 Subject: [PATCH 6/8] Rename 'modSource' to 'source' to match existing convention And to fix a bug! --- src/common/modals/CurseForgeModsBrowser.js | 2 +- src/common/modals/InstanceManager/Mods.js | 2 +- src/common/modals/ModOverview.js | 20 ++++++++++---------- src/common/modals/ModrinthModsBrowser.js | 2 +- src/common/modals/ModsBrowser.js | 8 ++++---- src/common/reducers/actions.js | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/common/modals/CurseForgeModsBrowser.js b/src/common/modals/CurseForgeModsBrowser.js index be4a5db61..c872abdcd 100644 --- a/src/common/modals/CurseForgeModsBrowser.js +++ b/src/common/modals/CurseForgeModsBrowser.js @@ -164,7 +164,7 @@ const ModsListWrapper = ({ const openModOverview = () => { dispatch( openModal('ModOverview', { - modSource: CURSEFORGE, + source: CURSEFORGE, gameVersions, projectID: item.id, ...(isInstalled && { fileID: isInstalled.fileID }), diff --git a/src/common/modals/InstanceManager/Mods.js b/src/common/modals/InstanceManager/Mods.js index d4d0ef08d..380a532c6 100644 --- a/src/common/modals/InstanceManager/Mods.js +++ b/src/common/modals/InstanceManager/Mods.js @@ -370,7 +370,7 @@ const Row = memo(({ index, style, data }) => { if (item.fileID) { dispatch( openModal('ModOverview', { - modSource: item.modSource, + source: item.source, projectID: item.projectID, fileID: item.fileID, fileName: item.fileName, diff --git a/src/common/modals/ModOverview.js b/src/common/modals/ModOverview.js index 339632ef9..3bf71d6bf 100644 --- a/src/common/modals/ModOverview.js +++ b/src/common/modals/ModOverview.js @@ -43,7 +43,7 @@ import { } from '../../app/desktop/utils'; const ModOverview = ({ - modSource, + source, projectID, fileID, gameVersions, @@ -73,7 +73,7 @@ const ModOverview = ({ const init = async () => { setLoadingFiles(true); - if (modSource === CURSEFORGE) { + if (source === CURSEFORGE) { await Promise.all([ getAddon(projectID).then(addon => { setAddon(addon); @@ -109,7 +109,7 @@ const ModOverview = ({ setLoadingFiles(false); }) ]); - } else if (modSource === MODRINTH) { + } else if (source === MODRINTH) { const project = await getModrinthProject(projectID); setMod(project); @@ -193,9 +193,9 @@ const ModOverview = ({ }; const handleChange = value => { - if (modSource === CURSEFORGE) { + if (source === CURSEFORGE) { setSelectedItem(JSON.parse(value)); - } else if (modSource === MODRINTH) { + } else if (source === MODRINTH) { setSelectedItem(value); } }; @@ -261,7 +261,7 @@ const ModOverview = ({ projectID, projectName: addon?.name || mod?.name, files, - type: modSource + type: source }) ); }} @@ -282,7 +282,7 @@ const ModOverview = ({ - {modSource === CURSEFORGE ? ( + {source === CURSEFORGE ? ( ReactHtmlParser(description) ) : ( {description} @@ -345,7 +345,7 @@ const ModOverview = ({ `} >
- {modSource === CURSEFORGE + {source === CURSEFORGE ? gameVersions : file.game_versions[0]}
@@ -400,7 +400,7 @@ const ModOverview = ({ ) ); } - if (modSource === CURSEFORGE) { + if (source === CURSEFORGE) { const newFile = dispatch( installMod( projectID, @@ -414,7 +414,7 @@ const ModOverview = ({ ) ); setInstalledData({ fileID: selectedItem, fileName: newFile }); - } else if (modSource === MODRINTH) { + } else if (source === MODRINTH) { const version = await getModrinthVersion(selectedItem); const newFile = dispatch( installModrinthMod(version, instanceName) diff --git a/src/common/modals/ModrinthModsBrowser.js b/src/common/modals/ModrinthModsBrowser.js index 0f0870b23..6367dc223 100644 --- a/src/common/modals/ModrinthModsBrowser.js +++ b/src/common/modals/ModrinthModsBrowser.js @@ -158,7 +158,7 @@ const ModsListWrapper = ({ const openModOverview = () => { dispatch( openModal('ModOverview', { - modSource: MODRINTH, + source: MODRINTH, gameVersion, projectID: item.project_id, ...(isInstalled && { fileID: isInstalled.fileID }), diff --git a/src/common/modals/ModsBrowser.js b/src/common/modals/ModsBrowser.js index 966ab11ad..c1a0235a6 100644 --- a/src/common/modals/ModsBrowser.js +++ b/src/common/modals/ModsBrowser.js @@ -10,7 +10,7 @@ import curseForgeIcon from '../assets/curseforgeIcon.webp'; import modrinthIcon from '../assets/modrinthIcon.webp'; const ModsBrowser = ({ instanceName, gameVersions }) => { - const [modSource, setModSource] = useState(CURSEFORGE); + const [source, setSource] = useState(CURSEFORGE); return ( {
setModSource(e.target.value)} + onChange={e => setSource(e.target.value)} > {
- {modSource === CURSEFORGE ? ( + {source === CURSEFORGE ? ( - ) : modSource === MODRINTH ? ( + ) : source === MODRINTH ? ( Date: Thu, 21 Jul 2022 17:29:04 +0100 Subject: [PATCH 7/8] Fixed 'Repair' button not actaully repairing the pack --- .../desktop/components/Instances/Instance.js | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/app/desktop/components/Instances/Instance.js b/src/app/desktop/components/Instances/Instance.js index bfe410c0e..75c1ac3af 100644 --- a/src/app/desktop/components/Instances/Instance.js +++ b/src/app/desktop/components/Instances/Instance.js @@ -389,14 +389,29 @@ const Instance = ({ instanceName }) => { disabled={Boolean(isInQueue) || Boolean(isPlaying)} onClick={async () => { let manifest = null; - try { + const isCursePack = await fs + .stat(path.join(instancesPath, instanceName, 'manifest.json')) + .then(() => true) + .catch(() => false); + + if (isCursePack) { + // CurseForge manifest = JSON.parse( await fs.readFile( path.join(instancesPath, instanceName, 'manifest.json') ) ); - } catch { - // NO-OP + } else { + // Modrinth + manifest = JSON.parse( + await fs.readFile( + path.join( + instancesPath, + instanceName, + 'modrinth.index.json' + ) + ) + ); } dispatch( From 75d82156f113207a81956e1683bf91295cdef521 Mon Sep 17 00:00:00 2001 From: Pyroglyph Date: Mon, 1 Aug 2022 17:46:21 +0100 Subject: [PATCH 8/8] Fix accidentally installing incompatible mods (wrong loader/version) --- src/common/modals/ModOverview.js | 2 +- src/common/modals/ModrinthModsBrowser.js | 11 ++- src/common/reducers/actions.js | 113 +++++++++++++++-------- 3 files changed, 83 insertions(+), 43 deletions(-) diff --git a/src/common/modals/ModOverview.js b/src/common/modals/ModOverview.js index 3bf71d6bf..fd44beefb 100644 --- a/src/common/modals/ModOverview.js +++ b/src/common/modals/ModOverview.js @@ -417,7 +417,7 @@ const ModOverview = ({ } else if (source === MODRINTH) { const version = await getModrinthVersion(selectedItem); const newFile = dispatch( - installModrinthMod(version, instanceName) + installModrinthMod(version, instanceName, gameVersion) ); setInstalledData({ fileID: selectedItem, fileName: newFile }); } diff --git a/src/common/modals/ModrinthModsBrowser.js b/src/common/modals/ModrinthModsBrowser.js index 6367dc223..0b1b04f5c 100644 --- a/src/common/modals/ModrinthModsBrowser.js +++ b/src/common/modals/ModrinthModsBrowser.js @@ -102,7 +102,8 @@ const ModsListWrapper = ({ searchQuery, width, height, - itemData + itemData, + instance }) => { // If there are more items to be loaded then add an extra row to hold a loading indicator. const itemCount = hasNextPage ? items.length + 3 : items.length; @@ -217,7 +218,11 @@ const ModsListWrapper = ({ item.project_id ); const compatibleModVersions = availableModVersions - .filter(v => v.game_versions.includes(gameVersion)) + .filter( + v => + v.game_versions.includes(gameVersion) && + v.loaders.includes(instance.loader?.loaderType) + ) .sort((a, b) => a.date_published - b.date_published); // prioritise stable releases, fall back to unstable releases if no compatible stable releases exist const latestCompatibleModVersion = @@ -239,6 +244,7 @@ const ModsListWrapper = ({ installModrinthMod( latestCompatibleModVersion, instanceName, + gameVersion, p => { if (parseInt(p, 10) !== prev) { prev = parseInt(p, 10); @@ -513,6 +519,7 @@ const ModrinthModsBrowser = ({ instanceName, gameVersion }) => { installedMods={installedMods} instanceName={instanceName} itemData={itemData} + instance={instance} /> )} diff --git a/src/common/reducers/actions.js b/src/common/reducers/actions.js index ad5ccdeea..c427c1139 100644 --- a/src/common/reducers/actions.js +++ b/src/common/reducers/actions.js @@ -60,8 +60,10 @@ import { getJavaLatestManifest, getJavaManifest, getMcManifest, - getModrinthVersionManifest, getModrinthCategories, + getModrinthProject, + getModrinthVersionManifest, + getModrinthVersions, getMultipleAddons, mcAuthenticate, mcInvalidate, @@ -73,7 +75,6 @@ import { msExchangeCodeForAccessToken, msMinecraftProfile, msOAuthRefresh, - getModrinthVersions, getVersionsFromHashes } from '../api'; import { @@ -3600,9 +3601,15 @@ export function installMod( /** * @param {ModrinthVersion} version * @param {string} instanceName + * @param {string} gameVersion * @param {Function} onProgress */ -export function installModrinthMod(version, instanceName, onProgress) { +export function installModrinthMod( + version, + instanceName, + gameVersion, + onProgress +) { return async (dispatch, getState) => { const state = getState(); const instancesPath = _getInstancesPath(state); @@ -3617,45 +3624,48 @@ export function installModrinthMod(version, instanceName, onProgress) { }) ); - const dependencies = (await resolveModrinthDependencies(version)).filter( + // TODO: this array sometimes contains an empty array? + const dependencies = ( + await resolveModrinthDependencies(version, gameVersion) + ).filter( dep => existingMods.find(mod => mod.fileID === dep.id) === undefined ); // install dependencies and the mod that we want await pMap( [...dependencies, version], - async v => { - const primaryFile = v.files.find(f => f.primary); - - const destFile = path.join(instancePath, 'mods', primaryFile.filename); - const tempFile = path.join(_getTempPath(state), primaryFile.filename); - - // download the mod - await downloadFile(tempFile, primaryFile.url, onProgress); - - // add mod to the mods list in the instance's config file - await dispatch( - updateInstanceConfig(instanceName, config => { - return { - ...config, - mods: [ - ...config.mods, - ...[ - { - source: MODRINTH, - projectID: v.project_id, - fileID: v.id, - fileName: primaryFile.filename, - displayName: primaryFile.filename, - downloadUrl: primaryFile.url - } + v => { + v.files?.forEach(async file => { + const destFile = path.join(instancePath, 'mods', file.filename); + const tempFile = path.join(_getTempPath(state), file.filename); + + // download the mod + await downloadFile(tempFile, file.url, onProgress); + + // add mod to the mods list in the instance's config file + await dispatch( + updateInstanceConfig(instanceName, config => { + return { + ...config, + mods: [ + ...config.mods, + ...[ + { + source: MODRINTH, + projectID: v.project_id, + fileID: v.id, + fileName: file.filename, + displayName: file.filename, + downloadUrl: file.url + } + ] ] - ] - }; - }) - ); + }; + }) + ); - await fse.move(tempFile, destFile, { overwrite: true }); + await fse.move(tempFile, destFile, { overwrite: true }); + }); }, { concurrency: 2 } ); @@ -3664,16 +3674,38 @@ export function installModrinthMod(version, instanceName, onProgress) { /** * Recursively gets all the dependent versions of a given version and returns them in one array - * @param {ModrinthVersion} version + * @param {ModrinthVersion} version The mod version to get the dependencies for + * @param {string} gameVersion The required Minecraft version, so we can ensure dependencies are compatible * @returns {Promise} */ -async function resolveModrinthDependencies(version) { +async function resolveModrinthDependencies(version, gameVersion) { // TODO: Ideally this function should be aware of mods the user already has installed and ignore them + // Note: version.dependencies[].version_id can sometimes be null. + // In this case we use the given project_id and select the most recent compatible version. + // Get the IDs for this version's required dependencies - const depVersionIDs = version.dependencies - .filter(v => v.dependency_type === 'required') - .map(v => v.version_id); + const depVersionIDs = await pMap( + version.dependencies.filter(dep => dep.dependency_type === 'required'), + async dep => { + if (dep.version_id) return dep.version_id; + + const project = await getModrinthProject(dep.project_id); + const availableModVersions = await getModrinthVersions(project.versions); + + // Get the latest compatible version + const compatibleModVersions = availableModVersions + .filter(v => v.game_versions.includes(gameVersion)) + .sort((a, b) => a.date_published - b.date_published); + // prioritise stable releases, fall back to unstable releases if no compatible stable releases exist + const latestCompatibleModVersion = + compatibleModVersions.find(v => v.version_type === 'release') ?? + compatibleModVersions.find(v => v.version_type === 'beta') ?? + compatibleModVersions.find(v => v.version_type === 'alpha'); + + return latestCompatibleModVersion.id; + } + ); // If this version does not depend on anything, return nothing if (depVersionIDs.length === 0) return []; @@ -3681,7 +3713,7 @@ async function resolveModrinthDependencies(version) { // If we do have dependencies, get the version objects for each of those and recurse on those const depVersions = await getModrinthVersions(depVersionIDs); const subDepVersions = await pMap(depVersions, async v => - resolveModrinthDependencies(v) + resolveModrinthDependencies(v, gameVersion) ); return [...depVersions, ...subDepVersions]; @@ -3702,6 +3734,7 @@ export const deleteMod = (instanceName, mod) => { }; }; +// TODO: Support Modrinth here export const updateMod = ( instanceName, mod,