diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..957ea60 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,3 @@ +{ + "include": ["src/**/*.js"] +} diff --git a/package-lock.json b/package-lock.json index 2979c6d..a08f05b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rplauncher", - "version": "1.4.5", + "version": "1.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "rplauncher", - "version": "1.4.5", + "version": "1.5.0", "cpu": [ "x64", "arm64" diff --git a/package.json b/package.json index ba3de7a..673e644 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rplauncher", - "version": "1.4.5", + "version": "1.5.0", "description": "rPLauncher is simple, yet powerful Minecraft custom launcher with a strong focus on the user experience", "keywords": [ "minecraft", diff --git a/src/app/desktop/utils/downloader.js b/src/app/desktop/utils/downloader.js index 043d65a..834fe75 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/app/desktop/utils/index.js b/src/app/desktop/utils/index.js index a1f6484..5378b8c 100644 --- a/src/app/desktop/utils/index.js +++ b/src/app/desktop/utils/index.js @@ -2,7 +2,7 @@ import fss, { promises as fs } from 'fs'; import originalFs from 'original-fs'; import fse from 'fs-extra'; import axios from 'axios'; -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'; @@ -16,11 +16,12 @@ import { FORGE, LATEST_JAVA_VERSION } from '../../../common/utils/constants'; - import { + addQuotes, removeDuplicates, sortByForgeVersionDesc } from '../../../common/utils'; +// eslint-disable-next-line import/no-cycle import { getAddon, getAddonFile, @@ -400,6 +401,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, @@ -407,7 +440,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, @@ -576,13 +609,14 @@ 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 => `"${l.path}"`) + .map(l => `${addQuotes(needsQuote, l.path)}`) .join(process.platform === 'win32' ? ';' : ':') ); @@ -594,8 +628,15 @@ export const getJVMArguments112 = ( args.push(`-Xmx${memory}m`); args.push(`-Xms${memory}m`); args.push(...jvmOptions); - args.push(`-Djava.library.path="${path.join(instancePath, 'natives')}"`); - args.push(`-Dminecraft.applet.TargetDirectory="${instancePath}"`); + args.push( + `-Djava.library.path=${addQuotes( + needsQuote, + path.join(instancePath, 'natives') + )}` + ); + args.push( + `-Dminecraft.applet.TargetDirectory=${addQuotes(needsQuote, instancePath)}` + ); if (mcJson.logging) { args.push(mcJson?.logging?.client?.argument || ''); } @@ -617,13 +658,13 @@ export const getJVMArguments112 = ( val = mcJson.id; break; case 'game_directory': - val = `"${instancePath}"`; + val = `${addQuotes(needsQuote, instancePath)}`; break; case 'assets_root': - val = `"${assetsPath}"`; + val = `${addQuotes(needsQuote, 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; @@ -652,6 +693,9 @@ export const getJVMArguments112 = ( if (val != null) { mcArgs[i] = val; } + if (typeof args[i] === 'string' && !needsQuote) { + args[i] = args[i].replaceAll('"', ''); + } } } @@ -679,6 +723,7 @@ 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"); @@ -687,7 +732,9 @@ export const getJVMArguments113 = ( args.push(`-Xmx${memory}m`); args.push(`-Xms${memory}m`); - args.push(`-Dminecraft.applet.TargetDirectory="${instancePath}"`); + args.push( + `-Dminecraft.applet.TargetDirectory=${addQuotes(needsQuote, instancePath)}` + ); if (mcJson.logging) { args.push(mcJson?.logging?.client?.argument || ''); } @@ -705,9 +752,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] = `"${args[i].value}"`; + args[i] = `${addQuotes(needsQuote, 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') { @@ -722,10 +769,10 @@ export const getJVMArguments113 = ( val = mcJson.id; break; case 'game_directory': - val = `"${instancePath}"`; + val = `${addQuotes(needsQuote, instancePath)}`; break; case 'assets_root': - val = `"${assetsPath}"`; + val = `${addQuotes(needsQuote, assetsPath)}`; break; case 'assets_index_name': val = mcJson.assets; @@ -751,7 +798,7 @@ export const getJVMArguments113 = ( case 'natives_directory': val = args[i].replace( argDiscovery, - `"${path.join(instancePath, 'natives')}"` + `${addQuotes(needsQuote, path.join(instancePath, 'natives'))}` ); break; case 'launcher_name': @@ -763,7 +810,7 @@ export const getJVMArguments113 = ( case 'classpath': val = [...libraries, mcjar] .filter(l => !l.natives) - .map(l => `"${l.path}"`) + .map(l => `${addQuotes(needsQuote, l.path)}`) .join(process.platform === 'win32' ? ';' : ':'); break; default: @@ -773,6 +820,7 @@ export const getJVMArguments113 = ( args[i] = val; } } + if (!needsQuote) args[i] = args[i].replaceAll('"', ''); } } diff --git a/src/app/desktop/views/Login.js b/src/app/desktop/views/Login.js index 61bf54f..9c38fda 100644 --- a/src/app/desktop/views/Login.js +++ b/src/app/desktop/views/Login.js @@ -336,7 +336,7 @@ const Login = () => {
setTwofactor(value)} css={` diff --git a/src/common/api.js b/src/common/api.js index 8418829..c1dd1d5 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, ELYBY_APIS, @@ -14,15 +17,30 @@ 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 modrinthClient = axios.create({ + baseURL: MODRINTH_API_URL, + headers: { + // 'User-Agent': `rePublic-Studios/rPLauncher/${appVersion}` + } +}); const trackFTBAPI = () => { ga.sendCustomEvent('FTBAPICall'); }; +const trackModrinthAPI = () => { + ga.sendCustomEvent('ModrinthAPICall'); +}; + // Microsoft Auth export const msExchangeCodeForAccessToken = ( clientId, @@ -341,7 +359,7 @@ export const getAddonFileChangelog = async (projectID, fileID) => { return data?.data; }; -export const getAddonCategories = async () => { +export const getCurseForgeCategories = async () => { const url = `${FORGESVC_URL}/categories?gameId=432`; const { data } = await axios.get(url); return data.data; @@ -353,7 +371,7 @@ export const getCFVersionIds = async () => { return data.data; }; -export const getSearch = async ( +export const getCurseForgeSearch = async ( type, searchFilter, pageSize, @@ -442,11 +460,262 @@ export const getFTBChangelog = async (modpackId, versionId) => { export const getFTBMostPlayed = async () => { trackFTBAPI(); const url = `${FTB_API_URL}/modpack/popular/plays/1000`; - return axios.get(url); + const { data } = await axios.get(url); + return data; }; export const getFTBSearch = async searchText => { trackFTBAPI(); const url = `${FTB_API_URL}/modpack/search/1000?term=${searchText}`; - return axios.get(url); + const { data } = axios.get(url); + return data; +}; +/** + * @param {number} offset + * @returns {Promise} + */ +export const getModrinthMostPlayedModpacks = async (offset = 0) => { + trackModrinthAPI(); + const url = `/search?limit=20&offset=${offset}&index=downloads&facets=[["project_type:modpack"]]`; + const { data } = await modrinthClient.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 modrinthClient.get(`/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 = `/projects?ids=${JSON.stringify(projectIds)}`; + const { data } = await modrinthClient.get(url); + return data.map(fixModrinthProjectObject); + } catch { + return { status: 'error' }; + } +}; + +/** + * @param {string} projectId + * @returns {Promise} + */ +export const getModrinthProjectVersions = async projectId => { + trackModrinthAPI(); + try { + const url = `/project/${projectId}/version`; + const { data } = await modrinthClient.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 = `versions?ids=${JSON.stringify(versionIds)}`; + const { data } = await modrinthClient.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(), 'rPLauncher_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 = `/user/${userId}`; + const { data } = await modrinthClient.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 = '/tag/category'; + const { data } = await modrinthClient.get(url); + return data; + } catch (err) { + console.error(err); + } +}; + +/** + * @param {string} projectId + * @returns {Promise} + */ +export const getModrinthProjectMembers = async projectId => { + trackModrinthAPI(); + try { + const url = `/project/${projectId}/members`; + const { data } = await modrinthClient.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 = '/version_files'; + const { data } = await modrinthClient.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 0000000..abab8f3 Binary files /dev/null and b/src/common/assets/modrinthIcon.webp differ diff --git a/src/common/modals/AddAccount.js b/src/common/modals/AddAccount.js index e3c82fa..b73bb30 100644 --- a/src/common/modals/AddAccount.js +++ b/src/common/modals/AddAccount.js @@ -129,7 +129,7 @@ const AddAccount = ({ username, _accountType, loginmessage }) => { onChange={e => setPassword(e.target.value)} /> setTwofactor(e.target.value)} /> diff --git a/src/common/modals/AddInstance/Content.js b/src/common/modals/AddInstance/Content.js index b51cdee..2fd0a21 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 + - props.version || props.importZipPath - ? props.theme.action.hover - : 'transparent'}; + props.version || props.importZipPath + ? props.theme.action.hover + : 'transparent'}; } `} onClick={async () => { diff --git a/src/common/modals/AddInstance/CurseForgeModpacks/index.js b/src/common/modals/AddInstance/CurseForgeModpacks/index.js index ccbbaf6..6d8dfce 100644 --- a/src/common/modals/AddInstance/CurseForgeModpacks/index.js +++ b/src/common/modals/AddInstance/CurseForgeModpacks/index.js @@ -7,7 +7,7 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { useSelector } from 'react-redux'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faBomb, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; -import { getSearch } from '../../../api'; +import { getCurseForgeSearch } from '../../../api'; import ModpacksListWrapper from './ModpacksListWrapper'; let lastRequest; @@ -17,8 +17,8 @@ const CurseForgeModpacks = ({ setStep, setVersion, setModpack }) => { const infiniteLoaderRef = useRef(null); const [modpacks, setModpacks] = useState([]); const [loading, setLoading] = useState(true); - const [minecraftVersion, setMinecraftVersion] = useState(null); - const [categoryId, setCategoryId] = useState(null); + const [minecraftVersion, setMinecraftVersion] = useState(''); + const [categoryId, setCategoryId] = useState(0); const [sortBy, setSortBy] = useState('Featured'); const [searchText, setSearchText] = useState(''); const [hasNextPage, setHasNextPage] = useState(false); @@ -46,7 +46,7 @@ const CurseForgeModpacks = ({ setStep, setVersion, setModpack }) => { if (error) { setError(false); } - data = await getSearch( + data = await getCurseForgeSearch( 'modpacks', searchText, 40, @@ -78,10 +78,10 @@ const CurseForgeModpacks = ({ setStep, setVersion, setModpack }) => { - All Versions + All Versions {(mcVersions || []) .filter(v => v?.type === 'release') .map(v => ( @@ -93,10 +93,10 @@ const CurseForgeModpacks = ({ setStep, setVersion, setModpack }) => { - + All Categories {(categories || []) diff --git a/src/common/modals/AddInstance/FTBModpacks/index.js b/src/common/modals/AddInstance/FTBModpacks/index.js index 65a27b0..aeb2914 100644 --- a/src/common/modals/AddInstance/FTBModpacks/index.js +++ b/src/common/modals/AddInstance/FTBModpacks/index.js @@ -32,6 +32,7 @@ const FTBModpacks = ({ setStep, setModpack, setVersion }) => { } else { data = await getFTBSearch(searchText); } + setModpackIds(data.packs || []); updateModpacks(); }; @@ -81,7 +82,6 @@ const FTBModpacks = ({ setStep, setModpack, setVersion }) => { setModpacks(newModpacks); } }; - return ( diff --git a/src/common/modals/AddInstance/InstanceName.js b/src/common/modals/AddInstance/InstanceName.js index d509e73..127ca90 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, @@ -78,15 +90,24 @@ 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 @@ -232,10 +254,10 @@ const InstanceName = ({ data.targets[0].name === FABRIC ? forgeModloader?.version : convertcurseForgeToCanonical( - forgeModloader?.version, - mcVersion, - forgeManifest - ), + forgeModloader?.version, + mcVersion, + forgeManifest + ), fileID: version?.fileID, projectID: version?.projectID, source: FTB, @@ -288,6 +310,66 @@ const InstanceName = ({ ramAmount ? { javaMemory: ramAmount } : null ) ); + } else if (isModrinthModpack) { + const manifest = await getModrinthVersionManifest( + version?.fileID, + path.join(instancesPath, localInstanceName) + ); + + const mcVersion = manifest.dependencies.minecraft; + const dependencies = Object.keys(manifest.dependencies); + let loaderType; + let loaderVersion; + if (dependencies.includes('fabric-loader')) { + loaderType = FABRIC; + loaderVersion = manifest.dependencies['fabric-loader']; + } else if (dependencies.includes('forge')) { + loaderType = FORGE; + loaderVersion = convertcurseForgeToCanonical( + manifest.dependencies['forge'], + mcVersion, + forgeManifest + ); + } else if (dependencies.includes('quilt-loader')) { + // we don't support Quilt yet, so we can't proceed with the installation + dispatch(closeModal()); + throw Error('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, @@ -419,6 +501,12 @@ const InstanceName = ({ size="large" placeholder={mcName} onChange={e => setInstanceName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + createInstance(instanceName || mcName); + setClicked(true); + } + }} css={` opacity: ${({ state }) => state === 'entering' || state === 'entered' @@ -576,7 +664,7 @@ const ModpackName = styled.span` font-weight: bold; font-size: 45px; animation: ${({ state }) => - state === 'entering' || state === 'entered' ? ModpackNameKeyframe : null} + state === 'entering' || state === 'entered' ? ModpackNameKeyframe : null} 0.2s ease-in-out forwards; box-sizing: border-box; text-align: center; @@ -589,7 +677,7 @@ const ModpackName = styled.span` box-sizing: border-box; position: absolute; border: ${({ state }) => - state === 'entering' || state === 'entered' ? 4 : 0}px + state === 'entering' || state === 'entered' ? 4 : 0}px solid transparent; width: 0; height: 0; @@ -600,23 +688,23 @@ const ModpackName = styled.span` border-top-color: white; border-right-color: white; animation: ${({ state }) => - state === 'entering' || state === 'entered' - ? ModpackNameBorderKeyframe - : null} + state === 'entering' || state === 'entered' + ? ModpackNameBorderKeyframe + : null} 2s infinite; } &::after { bottom: 0; right: 0; animation: ${({ state }) => - state === 'entering' || state === 'entered' - ? ModpackNameBorderKeyframe - : null} + state === 'entering' || state === 'entered' + ? ModpackNameBorderKeyframe + : null} 2s 1s infinite, ${({ state }) => - state === 'entering' || state === 'entered' - ? ModpackNameBorderColorKeyframe - : null} + state === 'entering' || state === 'entered' + ? ModpackNameBorderColorKeyframe + : null} 2s 1s infinite; } `; diff --git a/src/common/modals/AddInstance/ModrinthModpacks/ModpacksListWrapper.js b/src/common/modals/AddInstance/ModrinthModpacks/ModpacksListWrapper.js new file mode 100644 index 0000000..bc9649f --- /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 0000000..e87295e --- /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 0000000..be4a5db --- /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/InstanceDeleteConfirmation.js b/src/common/modals/InstanceDeleteConfirmation.js index 3351c05..9bc9974 100644 --- a/src/common/modals/InstanceDeleteConfirmation.js +++ b/src/common/modals/InstanceDeleteConfirmation.js @@ -53,7 +53,7 @@ const InstanceDeleteConfirmation = ({ instanceName }) => { data you have in this instance
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 378b71b..4c15353 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,99 @@ 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); + 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 - ); + 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 +199,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" > diff --git a/src/common/modals/InstanceManager/index.js b/src/common/modals/InstanceManager/index.js index 4865582..555e6c1 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 9d103d5..b4745b8 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 228b0b9..cf41c4d 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,60 @@ 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); - } + 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); - }) - ]); + 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 +137,11 @@ const ModOverview = ({ }; const getReleaseType = id => { + if (typeof id === 'string' || id instanceof 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)}
@@ -162,7 +226,7 @@ const ModOverview = ({
)} {(files || []).map(file => ( @@ -253,7 +324,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 7886c5b..4a88efc 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,70 @@ 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}` - ); + 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(async data => { - setFiles(data); - setLoading(false); - }) - ]); - } else if (type === 'ftb') { - setDescription(modpack.description); - setFiles(modpack.versions.slice().reverse()); - setLoading(false); + 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 +112,10 @@ const ModpackDescription = ({ const handleChange = value => setSelectedId(value); const getReleaseType = id => { + if (typeof id === 'string' || id instanceof 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 +200,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 +344,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 0000000..0f0870b --- /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 42a3176..fae03e4 100644 --- a/src/common/modals/ModsBrowser.js +++ b/src/common/modals/ModsBrowser.js @@ -1,428 +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 +71,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/modals/Settings/components/Java.js b/src/common/modals/Settings/components/Java.js index dd46d86..0a92d1e 100644 --- a/src/common/modals/Settings/components/Java.js +++ b/src/common/modals/Settings/components/Java.js @@ -170,7 +170,7 @@ export default function MyAccountPreferences() { `} > Disable this to specify a custom java path to use instead of using - OpenJDK shipped with GDLauncher. If that is the case, select the path + OpenJDK shipped with rPLauncher. If that is the case, select the path to your Java executable. { - 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 = {}; @@ -221,18 +239,39 @@ 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 { @@ -240,7 +279,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 @@ -1945,7 +1989,16 @@ export function processForgeManifest(instanceName) { if (!fileExists) { if (!modManifest.downloadUrl) { - optedOutMods.push({ addon, modManifest }); + const normalizedModData = normalizeModData( + modManifest, + item.projectID, + addon.name + ); + + optedOutMods.push({ + addon, + modManifest: normalizedModData + }); return; } await downloadFile(destFile, modManifest.downloadUrl); @@ -2105,6 +2158,85 @@ export function processForgeManifest(instanceName) { }; } +export function processModrinthManifest(instanceName) { + return async (dispatch, getState) => { + const state = getState(); + /** @type {{manifest: ModrinthManifest}} */ + const { manifest } = _getCurrentDownloadItem(state); + const { files } = manifest; + + 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)); + } + }; + + dispatch(updateDownloadStatus(instanceName, 'Downloading pack...')); + await downloadInstanceFilesWithFallbacks( + files, + instancePath, + updatePercentage, + state.settings.concurrentDownloads + ); + + 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(); @@ -2275,18 +2407,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}`); + } + } + } // Adding global settings await addGlobalSettings( @@ -2325,149 +2472,215 @@ 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); - - const manifest = await fse.readJson( - path.join(instancePath, 'manifest.json') - ); + switch (instance.loader.source) { + case CURSEFORGE: { + const addon = await getAddon(instance.loader?.projectID); + const manifest = await fse.readJson( + path.join(instancePath, 'manifest.json') + ); - await fse.remove(path.join(instancePath, 'manifest.json')); + 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}`); + } } }; }; @@ -2939,6 +3152,7 @@ export function launchInstance(instanceName, forceQuit = false) { const { userData } = state; const account = _getCurrentAccount(state); const librariesPath = _getLibrariesPath(state); + const exeData = await ipcRenderer.invoke('getExecutablePath'); const assetsPath = _getAssetsPath(state); const { memory, args } = state.settings.java; const { resolution: globalMinecraftResolution } = @@ -3155,20 +3369,18 @@ export function launchInstance(instanceName, forceQuit = false) { mcJson.forge = { arguments: {} }; mcJson.forge.arguments.jvm = forgeJson.version.arguments.jvm.map( arg => { - 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' ? ';' : ':' - ); + return replaceLibraryDirectory( + arg + .replace(/\${version_name}/g, mcJson.id) + .replace( + /=\${library_directory}/g, + `="${_getLibrariesPath(state)}"` + ), + _getLibrariesPath(state) + ).replace( + /\${classpath_separator}/g, + process.platform === 'win32' ? '";' : '":' + ); } ); } @@ -3229,7 +3441,11 @@ export function launchInstance(instanceName, forceQuit = false) { ? getJVMArguments113 : getJVMArguments112; - const javaArguments = (javaArgs !== undefined ? javaArgs : args).split(' '); + const javaArguments = `${ + account.accountType === ACCOUNT_ELYBY + ? `-javaagent:${exeData}\\authlib-injector.jar=ely.by` + : `` + } ${javaArgs !== undefined ? javaArgs : args}`.split(' '); const javaMem = javaMemory !== undefined ? javaMemory : memory; const gameResolution = instanceResolution || globalMinecraftResolution; @@ -3249,7 +3465,7 @@ export function launchInstance(instanceName, forceQuit = false) { const { mcStartupMethod } = state.settings; let replaceWith = `..${path.sep}..`; - const symLinkDirPath = path.join(userData.split('\\')[0], '_gdl'); + const symLinkDirPath = path.join(userData.split('\\')[0], '_rpl'); if (MC_STARTUP_METHODS[mcStartupMethod] === MC_STARTUP_METHODS.SYMLINK) { replaceWith = symLinkDirPath; if (process.platform === 'win32') await symlink(userData, symLinkDirPath); @@ -3271,8 +3487,9 @@ export function launchInstance(instanceName, forceQuit = false) { loggingId || '' ); + const needsQuote = process.platform !== 'win32'; console.log( - `"${javaPath}" ${getJvmArguments( + `${addQuotes(needsQuote, javaPath)} ${getJvmArguments( libraries, mcMainFile, instancePath, @@ -3288,7 +3505,7 @@ export function launchInstance(instanceName, forceQuit = false) { .replace( // eslint-disable-next-line no-template-curly-in-string '-Dlog4j.configurationFile=${path}', - `-Dlog4j.configurationFile="${loggingPath}"` + `-Dlog4j.configurationFile=${addQuotes(needsQuote, loggingPath)}` ) ); @@ -3299,7 +3516,7 @@ export function launchInstance(instanceName, forceQuit = false) { let closed = false; const ps = spawn( - `"${javaPath}"`, + `${addQuotes(needsQuote, javaPath)}`, jvmArguments.map(v => v .toString() @@ -3307,12 +3524,12 @@ export function launchInstance(instanceName, forceQuit = false) { .replace( // eslint-disable-next-line no-template-curly-in-string '-Dlog4j.configurationFile=${path}', - `-Dlog4j.configurationFile="${loggingPath}"` + `-Dlog4j.configurationFile=${addQuotes(needsQuote, loggingPath)}` ) ), { cwd: instancePath, - shell: true + shell: process.platform !== 'win32' } ); @@ -3581,6 +3798,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()); @@ -3674,7 +3981,7 @@ export const initLatestMods = instanceName => { export const isNewVersionAvailable = async () => { const { data: latestReleases } = await axios.get( - 'https://api.github.com/repos/gorilla-devs/GDLauncher/releases?per_page=10' + 'https://api.github.com/repos/rePublic-Studios/rPLauncher/releases?per_page=10' ); const latestPrerelease = latestReleases.find(v => v.prerelease); @@ -3686,7 +3993,7 @@ export const isNewVersionAvailable = async () => { try { const rChannel = await fs.readFile( - path.join(appData, 'gdlauncher_next', 'rChannel') + path.join(appData, 'rplauncher_next', 'rChannel') ); releaseChannel = parseInt(rChannel.toString(), 10); } catch { @@ -3730,7 +4037,7 @@ export const checkForPortableUpdates = () => { // Latest version has a value only if the user is not using the latest if (newVersion) { - const baseAssetUrl = `https://github.com/gorilla-devs/GDLauncher/releases/download/${newVersion?.tag_name}`; + const baseAssetUrl = `https://github.com/rePublic-Studios/rPLauncher/releases/download/${newVersion?.tag_name}`; const { data: latestManifest } = await axios.get( `${baseAssetUrl}/${process.platform}_latest.json` ); diff --git a/src/common/reducers/app.js b/src/common/reducers/app.js index 668ade3..aa65f53 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 b72d010..6efe428 100644 --- a/src/common/utils/constants.js +++ b/src/common/utils/constants.js @@ -18,6 +18,7 @@ export const MAVEN_REPO = 'http://central.maven.org/maven2'; export const MC_LIBRARIES_URL = 'https://libraries.minecraft.net'; 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'; @@ -30,6 +31,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_ELYBY = 'ACCOUNT_ELYBY'; diff --git a/src/common/utils/index.js b/src/common/utils/index.js index 5c22424..68f088f 100644 --- a/src/common/utils/index.js +++ b/src/common/utils/index.js @@ -285,3 +285,22 @@ 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 0000000..d2fb602 --- /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 + */