Merge pull request #203 from Polyfrost/feat/curseforge

Feature: Implement Curseforge Package API
This commit is contained in:
LynithDev 2024-10-17 20:48:13 +02:00 • committed by GitHub
commit 3745ed03ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1681 additions and 467 deletions

3
.template.env Normal file
View file

@ -0,0 +1,3 @@
#! Use single quotes due to special characters and dotenvy being weird
CURSEFORGE_API_KEY = ''

9
Cargo.lock generated
View file

@ -1672,6 +1672,11 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "git+https://github.com/allan2/dotenvy/?rev=db0d6aa8fc8d1b0ddbfe94b5c833372e463eb1c5#db0d6aa8fc8d1b0ddbfe94b5c833372e463eb1c5"
[[package]]
name = "dpi"
version = "0.1.1"
@ -4375,6 +4380,7 @@ dependencies = [
"chrono",
"dirs 5.0.1",
"discord-rich-presence",
"dotenvy 0.15.7 (git+https://github.com/allan2/dotenvy/?rev=db0d6aa8fc8d1b0ddbfe94b5c833372e463eb1c5)",
"flate2",
"futures",
"indicatif",
@ -4395,6 +4401,7 @@ dependencies = [
"serde",
"serde_ini",
"serde_json",
"serde_repr",
"sha1_smol",
"sha2 0.10.8",
"specta 2.0.0-rc.20",
@ -5170,7 +5177,7 @@ dependencies = [
"bigdecimal 0.4.5",
"chrono",
"diagnostics",
"dotenvy",
"dotenvy 0.15.7 (registry+https://github.com/rust-lang/crates.io-index)",
"futures",
"include_dir",
"indexmap 1.9.3",

View file

@ -112,6 +112,7 @@ zeroize = { version = "1.8", features = [ "zeroize_derive" ] }
serde = { version = "1.0", features = [ "derive" ] }
serde_json = { version = "1.0" }
serde_ini = { version = "0.2" }
serde_repr = { version = "0.1" }
regex = { version = "1.10" }
url = { version = "2.5.2" }
strum = { version = "0.26", features = [ "derive" ] }
@ -179,6 +180,7 @@ cocoa = { version = "0.26" }
objc = { version = "0.2" }
cc = { version = "1.1" }
libc = { version = "0.2" }
dotenvy = { git = "https://github.com/allan2/dotenvy/", rev = "db0d6aa8fc8d1b0ddbfe94b5c833372e463eb1c5" }
# https://github.com/tauri-apps/tauri/blob/dev/crates/tauri/Cargo.toml#L102
webkit2gtk = { version = "=2.0.1", features = [ "v2_40" ] }

View file

@ -67,6 +67,7 @@ macro_rules! collect_commands {
get_provider_package_version,
search_provider_packages,
get_provider_authors,
get_package_body,
download_provider_package,
// Cluster Packages
get_cluster_package,

View file

@ -4,7 +4,10 @@ use onelauncher::cluster::content::package;
use onelauncher::data::{Loader, ManagedPackage, ManagedUser, ManagedVersion, PackageType};
use onelauncher::package::content::Providers;
use onelauncher::package::import::{default_launcher_path, ImportType};
use onelauncher::store::{Author, ClusterPath, Package, PackagePath, ProviderSearchResults};
use onelauncher::store::{
Author, ClusterPath, Package, PackageBody, PackagePath, ProviderSearchResults,
};
use onelauncher::utils::pagination::Pagination;
use uuid::Uuid;
#[specta::specta]
@ -49,9 +52,11 @@ pub async fn get_all_provider_package_versions(
project_id: String,
game_versions: Option<Vec<String>>,
loaders: Option<Vec<Loader>>,
) -> Result<Vec<ManagedVersion>, String> {
page: Option<u32>,
page_size: Option<u16>,
) -> Result<(Vec<ManagedVersion>, Pagination), String> {
Ok(provider
.get_all_versions(&project_id, game_versions, loaders)
.get_all_versions(&project_id, game_versions, loaders, page, page_size)
.await?)
}
@ -70,7 +75,7 @@ pub async fn get_provider_package_version(
provider: Providers,
version: String,
) -> Result<ManagedVersion, String> {
Ok(provider.get_version(&version).await?)
Ok(provider.get_version("", &version).await?) // TODO
}
#[derive(specta::Type, serde::Deserialize, serde::Serialize)]
@ -114,6 +119,12 @@ pub async fn get_provider_authors(
Ok(provider.get_authors(&author).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn get_package_body(provider: Providers, body: PackageBody) -> Result<String, String> {
Ok(provider.get_package_body(&body).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn download_provider_package(
@ -138,6 +149,7 @@ pub async fn download_provider_package(
package_version,
)
.await?;
package::add_package(
&cluster.cluster_path(),
pkg_path,

View file

@ -11,7 +11,6 @@ interface SearchResultsContainerProps {
provider: Providers;
results: SearchResult[];
header: string | JSX.Element;
category: string;
collapsable?: boolean;
}
@ -56,7 +55,7 @@ function SearchResultsContainer(props: SearchResultsContainerProps) {
</div>
<Show when={collapsed() === true}>
<div class="absolute bottom-4 left-1/2 z-2 -translate-x-1/2">
<div class="w-full flex items-center justify-center">
<Button
buttonStyle="secondary"
children="Show more"
@ -146,7 +145,7 @@ function PackageItem(props: SearchResult & { provider: Providers; row?: boolean
</div>
<div class="flex flex-1 flex-col gap-2 p-3">
<div class="flex flex-col gap-2">
<h4 class="text-fg-primary font-medium">{props.title}</h4>
<h4 class="text-fg-primary font-medium line-height-normal">{props.title}</h4>
<p class="text-xs text-fg-secondary">
By
{' '}
@ -166,10 +165,12 @@ function PackageItem(props: SearchResult & { provider: Providers; row?: boolean
{abbreviateNumber(props.downloads)}
</div>
<div class="flex flex-row items-center gap-2">
<HeartIcon class="h-4 w-4" />
{abbreviateNumber(props.follows)}
</div>
<Show when={props.follows > 0}>
<div class="flex flex-row items-center gap-2">
<HeartIcon class="h-4 w-4" />
{abbreviateNumber(props.follows)}
</div>
</Show>
</div>
</div>
</div>

View file

@ -1,16 +1,15 @@
import type { Cluster, ManagedPackage, PackageType, Providers, ProviderSearchQuery, ProviderSearchResults } from '@onelauncher/client/bindings';
import type { ModalProps } from '~ui/components/overlay/Modal';
import type { Cluster, ManagedPackage, PackageType, Providers, ProviderSearchQuery, SearchResult } from '@onelauncher/client/bindings';
import { useNavigate } from '@solidjs/router';
import { bridge } from '~imports';
import Button from '~ui/components/base/Button';
import Dropdown from '~ui/components/base/Dropdown';
import Modal, { createModal } from '~ui/components/overlay/Modal';
import { createModal } from '~ui/components/overlay/Modal';
import BrowserPackage from '~ui/pages/browser/BrowserPackage';
import { type Accessor, type Context, createContext, createEffect, createSignal, For, on, onMount, type ParentProps, type Setter, Show, untrack, useContext } from 'solid-js';
import { useRecentCluster } from './useCluster';
import useCommand, { tryResult } from './useCommand';
import { PROVIDERS } from '~utils';
import { type Accessor, type Context, createContext, createEffect, createSignal, on, onMount, type ParentProps, type Setter, useContext } from 'solid-js';
import { ChooseClusterModal, useRecentCluster } from './useCluster';
import { tryResult } from './useCommand';
export type ProviderSearchOptions = ProviderSearchQuery & { provider: Providers };
export type PopularPackages = Record<Providers, SearchResult[]>;
interface BrowserControllerType {
cluster: Accessor<Cluster | undefined>;
@ -18,7 +17,6 @@ interface BrowserControllerType {
displayBrowser: (cluster?: Cluster | undefined) => void;
displayPackage: (id: string, provider: Providers) => void;
displayCategory: (category: string) => void;
displayClusterSelector: () => void;
search: () => void;
@ -28,16 +26,15 @@ interface BrowserControllerType {
packageType: Accessor<PackageType>;
setPackageType: Setter<PackageType>;
refreshCache: () => void;
cache: Accessor<ProviderSearchResults | undefined>;
featured: Accessor<ManagedPackage | undefined>;
popularPackages: Accessor<PopularPackages | undefined>;
featuredPackage: Accessor<ManagedPackage | undefined>;
};
const BrowserContext = createContext() as Context<BrowserControllerType>;
export function BrowserProvider(props: ParentProps) {
const [mainPageCache, setMainPageCache] = createSignal<ProviderSearchResults>();
const [featured, setFeatured] = createSignal<ManagedPackage | undefined>();
const [popularPackages, setPopularPackages] = createSignal<PopularPackages | undefined>();
const [featuredPackage, setFeaturedPackage] = createSignal<ManagedPackage | undefined>();
// Used for the current "Browser Mode". It'll only show packages of the selected type
const [packageType, setPackageType] = createSignal<PackageType>('mod');
@ -48,7 +45,7 @@ export function BrowserProvider(props: ParentProps) {
const [searchOptions, setSearchOptions] = createSignal<ProviderSearchOptions>({
provider: 'Modrinth',
query: '',
limit: 20,
limit: 18, // Even multiples of 3 recommended for grid view (normally 3 columns, 2 columns on very small windows)
offset: 0,
categories: null,
game_versions: null,
@ -60,8 +57,30 @@ export function BrowserProvider(props: ParentProps) {
const navigate = useNavigate();
const recentCluster = useRecentCluster();
const currentlySelectedCluster = (list: Cluster[]) => {
const cluster = controller.cluster();
if (cluster === undefined)
return 0;
if (list === undefined)
return 0;
const index = list.findIndex(c => c.uuid === cluster.uuid) || 0;
if (index === -1)
return 0;
return index;
};
const chooseCluster = (cluster: Cluster) => {
controller.setCluster(cluster);
};
const modal = createModal(props => (
<ChooseClusterModal
onSelected={chooseCluster}
selected={list => currentlySelectedCluster(list) || 0}
{...props}
/>
));
@ -78,45 +97,55 @@ export function BrowserProvider(props: ParentProps) {
navigate(BrowserPackage.buildUrl({ id, provider }));
},
displayCategory(_category: string) {
},
displayClusterSelector() {
modal.show();
},
search: () => {
navigate('/browser/search');
},
search: () => navigate('/browser/search'),
searchQuery: searchOptions,
setSearchQuery: setSearchOptions,
packageType,
setPackageType,
async refreshCache() {
const opts = untrack(searchOptions);
const res = await tryResult(() => bridge.commands.searchProviderPackages('Modrinth', opts));
setMainPageCache(res);
// TODO: Better algorithm for selecting a featured package
const firstPackage = res.results[0];
if (firstPackage !== undefined) {
const featuredPackage = await tryResult(() => bridge.commands.getProviderPackage('Modrinth', firstPackage.project_id));
setFeatured(featuredPackage);
}
},
cache: mainPageCache,
featured,
popularPackages,
featuredPackage,
};
onMount(() => {
if (mainPageCache() === undefined)
controller.refreshCache();
onMount(async () => {
const getOpts = (provider: Providers): ProviderSearchOptions => ({
provider,
query: '',
limit: 10,
offset: 0,
categories: null,
game_versions: null,
loaders: null,
package_types: ['mod'],
open_source: null,
});
const response = await Promise.allSettled(
PROVIDERS.map(provider => tryResult(() => bridge.commands.searchProviderPackages(provider, getOpts(provider)))),
);
const popularPackages = response.reduce((acc, res, i) => {
const provider = PROVIDERS[i] as Providers;
if (res.status === 'fulfilled')
acc[provider] = res.value.results;
return acc;
}, {} as PopularPackages);
setPopularPackages(popularPackages);
// TODO: Better algorithm for selecting a featured package
const firstPackage = popularPackages.Modrinth[0] || popularPackages.Curseforge[0];
if (firstPackage !== undefined) {
const featuredPackage = await tryResult(() => bridge.commands.getProviderPackage('Modrinth', firstPackage.project_id));
setFeaturedPackage(featuredPackage);
}
});
createEffect(() => {
@ -153,73 +182,3 @@ export default function useBrowser() {
return controller;
}
function ChooseClusterModal(props: ModalProps) {
const [selected, setSelected] = createSignal<number>(0);
const [clusters] = useCommand(() => bridge.commands.getClusters());
const controller = useBrowser();
const currentlySelectedCluster = (list: Cluster[]) => {
const cluster = controller.cluster();
if (cluster === undefined)
return 0;
if (list === undefined)
return 0;
const index = list.findIndex(c => c.uuid === cluster.uuid) || 0;
if (index === -1)
return 0;
return index;
};
createEffect(() => {
setSelected(currentlySelectedCluster(clusters() || []));
});
function chooseCluster() {
const index = untrack(selected);
const clusterz = untrack(clusters);
if (clusterz !== undefined && index !== undefined)
controller.setCluster(clusterz[index]);
props.hide();
}
return (
<Modal.Simple
{...props}
buttons={[
<Button
buttonStyle="secondary"
children="Close"
onClick={props.hide}
/>,
<Button
children="Save"
onClick={chooseCluster}
/>,
]}
children={(
<Show
fallback={<div>Loading...</div>}
when={clusters !== undefined}
>
<Dropdown
onChange={setSelected}
selected={selected}
>
<For each={clusters()!}>
{cluster => (
<Dropdown.Row>{cluster.meta.name}</Dropdown.Row>
)}
</For>
</Dropdown>
</Show>
)}
title="Choose a cluster"
/>
);
}

View file

@ -1,9 +1,11 @@
import type { Cluster } from '@onelauncher/client/bindings';
import { useNavigate } from '@solidjs/router';
import { bridge } from '~imports';
import Modal, { createModal } from '~ui/components/overlay/Modal';
import Button from '~ui/components/base/Button';
import Dropdown from '~ui/components/base/Dropdown';
import Modal, { createModal, type ModalProps } from '~ui/components/overlay/Modal';
import ClusterGame from '~ui/pages/cluster/ClusterGame';
import { type Accessor, type Context, createContext, createEffect, createSignal, type ParentProps, type ResourceReturn, Show, useContext } from 'solid-js';
import { type Accessor, type Context, createContext, createEffect, createResource, createSignal, For, Match, onMount, type ParentProps, type ResourceReturn, Show, Switch, untrack, useContext } from 'solid-js';
import useCommand, { tryResult } from './useCommand';
const ClusterContext = createContext() as Context<ResourceReturn<Cluster>>;
@ -104,3 +106,82 @@ export function useRecentCluster() {
}
export default useClusterContext;
type ChooseClusterModalProps = ModalProps & {
clusters?: () => Cluster[];
selected?: (clusters: Cluster[]) => number;
onSelected?: (cluster: Cluster) => void;
};
export function ChooseClusterModal(props: ChooseClusterModalProps) {
const [clusters] = createResource(async () => {
if (props.clusters !== undefined)
return await props.clusters();
return await tryResult(bridge.commands.getClusters);
});
const [selected, setSelected] = createSignal<number>(0);
onMount(() => {
const newSelected = props.selected?.(clusters() || []);
if (newSelected !== undefined)
setSelected(newSelected);
});
function chooseCluster() {
const index = untrack(selected);
const clusterz = untrack(clusters);
if (clusterz !== undefined && index !== undefined) {
const cluster = clusterz[index];
if (cluster !== undefined)
props.onSelected?.(cluster);
}
props.hide();
}
return (
<Modal.Simple
{...props}
buttons={[
<Button
buttonStyle="secondary"
children="Close"
onClick={props.hide}
/>,
<Button
children="Save"
onClick={chooseCluster}
/>,
]}
children={(
<Switch>
<Match when={clusters() !== undefined}>
<Dropdown
disabled={clusters()?.length === 0}
onChange={setSelected}
selected={selected}
>
<Switch>
<Match when={(clusters()?.length || 0) > 0}>
<For each={clusters()!}>
{cluster => (
<Dropdown.Row>{cluster.meta.name}</Dropdown.Row>
)}
</For>
</Match>
<Match when>
<Dropdown.Row>No clusters found</Dropdown.Row>
</Match>
</Switch>
</Dropdown>
</Match>
<Match when>
<div>Loading...</div>
</Match>
</Switch>
)}
title="Choose a cluster"
/>
);
}

View file

@ -108,6 +108,7 @@ function usePagination(initialOptions: PaginationOptions) {
totalPages,
offset,
reset,
options,
Navigation,
};
}

View file

@ -1,10 +1,10 @@
import type { ManagedPackage } from '@onelauncher/client/bindings';
import type { ManagedPackage, Providers } from '@onelauncher/client/bindings';
import { ChevronRightIcon } from '@untitled-theme/icons-solid';
import OneConfigLogo from '~assets/logos/oneconfig.svg?component-solid';
import Button from '~ui/components/base/Button';
import SearchResultsContainer from '~ui/components/content/SearchResults';
import useBrowser from '~ui/hooks/useBrowser';
import { onMount, Show } from 'solid-js';
import { For, onMount, Show } from 'solid-js';
import { BrowserContent } from './BrowserRoot';
function BrowserMain() {
@ -20,15 +20,19 @@ function BrowserMain() {
return (
<BrowserContent>
<div class="flex flex-col gap-8">
<Show when={browser.cache() !== undefined && browser.featured() !== undefined}>
<Featured package={browser.featured()!} />
<Show when={browser.popularPackages() !== undefined && browser.featuredPackage() !== undefined}>
<Featured package={browser.featuredPackage()!} />
<SearchResultsContainer
category="modrinth"
collapsable
header="Modrinth"
{...browser.cache()!}
/>
<For each={Object.entries(browser.popularPackages()!)}>
{([provider, results]) => (
<SearchResultsContainer
collapsable
header={provider}
provider={provider as Providers}
results={results}
/>
)}
</For>
</Show>
</div>
</BrowserContent>

View file

@ -1,4 +1,4 @@
import type { Cluster, ManagedPackage, ManagedUser, ManagedVersion, Providers } from '@onelauncher/client/bindings';
import type { Cluster, ManagedPackage, ManagedVersion, Providers } from '@onelauncher/client/bindings';
import { A, type Params, Route, useSearchParams } from '@solidjs/router';
import { CalendarIcon, ChevronDownIcon, ClockRewindIcon, Download01Icon, File02Icon, HeartIcon, LinkExternal01Icon } from '@untitled-theme/icons-solid';
import SteveHead from '~assets/images/steve.png';
@ -9,13 +9,15 @@ import Dropdown from '~ui/components/base/Dropdown';
import Link from '~ui/components/base/Link';
import Tooltip from '~ui/components/base/Tooltip';
import Markdown from '~ui/components/content/Markdown';
import { createModal } from '~ui/components/overlay/Modal';
import Spinner from '~ui/components/Spinner';
import useBrowser from '~ui/hooks/useBrowser';
import useCommand from '~ui/hooks/useCommand';
import { ChooseClusterModal } from '~ui/hooks/useCluster';
import useCommand, { tryResult } from '~ui/hooks/useCommand';
import usePagination from '~ui/hooks/usePagination';
import usePromptOpener from '~ui/hooks/usePromptOpener';
import { abbreviateNumber, formatAsRelative } from '~utils';
import { createContext, createEffect, createSignal, For, Match, on, type ParentProps, Show, Switch, useContext } from 'solid-js';
import { type Accessor, createContext, createEffect, createMemo, createResource, createSignal, For, Match, on, type ParentProps, type Resource, Show, Switch, useContext } from 'solid-js';
import { getLicenseUrl, getPackageUrl, upperFirst } from '../../../utils';
interface BrowserModParams extends Params {
@ -23,14 +25,25 @@ interface BrowserModParams extends Params {
provider: Providers;
}
const BrowserPackageContext = createContext<ManagedPackage | null>(null);
interface BrowserPackageContextType {
pkg: Accessor<ManagedPackage>;
body: Resource<string>;
};
const BrowserPackageContext = createContext<BrowserPackageContextType | null>(null);
const basePath = '/browser/package';
function BrowserPackageProvider(props: ParentProps & { pkg: ManagedPackage }) {
const [body] = useCommand(() => bridge.commands.getPackageBody(props.pkg.provider, props.pkg.body));
const ctx: BrowserPackageContextType = {
pkg: () => props.pkg,
body,
};
return (
// eslint-disable-next-line solid/reactivity -- Should be fine
<BrowserPackageContext.Provider value={props.pkg}>
<BrowserPackageContext.Provider value={ctx}>
{props.children}
</BrowserPackageContext.Provider>
);
@ -67,7 +80,6 @@ function NavLink(props: ParentProps & { href: string }) {
function BrowserPackage(props: ParentProps) {
const [params] = useSearchParams<BrowserModParams>();
const [pkg] = useCommand(() => bridge.commands.getProviderPackage(params.provider!, params.id!));
const [authors] = useCommand(pkg, () => bridge.commands.getProviderAuthors(pkg()!.provider, pkg()!.author));
const links = [
['About', '/'],
@ -81,7 +93,7 @@ function BrowserPackage(props: ParentProps) {
<Show
children={(
<>
<BrowserSidebar authors={authors()!} package={pkg()!} />
<BrowserSidebar package={pkg()!} />
<div class="min-h-full flex flex-1 flex-col items-start gap-y-4 pb-8">
<div class="flex flex-none flex-row gap-x-1 rounded-lg bg-component-bg p-1">
@ -108,7 +120,7 @@ function BrowserPackage(props: ParentProps) {
</div>
</>
)}
when={pkg() !== undefined && authors() !== undefined}
when={pkg() !== undefined}
/>
</div>
</Spinner.Suspense>
@ -123,7 +135,8 @@ BrowserPackage.Routes = BrowserPackageRoutes;
export default BrowserPackage;
function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[] }) {
function BrowserSidebar(props: { package: ManagedPackage }) {
const [authors] = useCommand(() => props.package, () => bridge.commands.getProviderAuthors(props.package.provider, props.package.author));
const createdAt = () => new Date(props.package.created);
const updatedAt = () => new Date(props.package.updated);
const promptOpen = usePromptOpener();
@ -137,7 +150,7 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
</div>
<div class="flex flex-1 flex-col gap-2 p-3">
<div class="flex flex-col gap-2">
<h4 class="text-fg-primary font-medium">{props.package.title}</h4>
<h4 class="text-fg-primary font-medium line-height-snug">{props.package.title}</h4>
<p class="text-xs text-fg-secondary">
<span class="text-fg-primary capitalize">{props.package.package_type}</span>
{' '}
@ -158,10 +171,12 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
{abbreviateNumber(props.package.downloads)}
</div>
<div class="flex flex-row items-center gap-2">
<HeartIcon class="h-4 w-4" />
{abbreviateNumber(props.package.followers)}
</div>
<Show when={props.package.followers > 0}>
<div class="flex flex-row items-center gap-2">
<HeartIcon class="h-4 w-4" />
{abbreviateNumber(props.package.followers)}
</div>
</Show>
</div>
</div>
</div>
@ -170,7 +185,7 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
<div class="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
<h4 class="text-fg-primary font-bold">Links</h4>
<Link href={getPackageUrl(props.package.provider, props.package.id, props.package.package_type)} includeIcon>
<Link href={getPackageUrl(props.package)} includeIcon>
{props.package.provider}
{' '}
Page
@ -179,14 +194,14 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
<div class="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
<h4 class="text-fg-primary font-bold">Authors</h4>
<For each={props.authors}>
<For each={authors()}>
{author => (
<>
<div
class="flex flex-row items-center gap-x-1 rounded-md p-1 active:bg-component-bg-pressed hover:bg-component-bg-hover"
onClick={() => promptOpen(author.url)}
>
<img alt={`${author.username}'s avatar`} class="h-8 min-h-8 min-w-8 w-8 rounded-md" src={author.avatar_url || SteveHead} />
<img alt={`${author.username}'s avatar`} class="h-8 min-h-8 min-w-8 w-8 rounded-[5px]" src={author.avatar_url || SteveHead} />
<div class="flex flex-1 flex-col justify-center gap-y-1">
<span>{author.username}</span>
@ -200,7 +215,7 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
</div>
<LinkExternal01Icon class="h-4 w-4" />
</div>
<Show when={author.is_organization_user === true && props.authors.length > 1}>
<Show when={author.is_organization_user === true && ((authors()?.length || 0) > 1)}>
<div class="h-px w-full bg-gray-05" />
</Show>
</>
@ -257,7 +272,7 @@ function InstallButton(props: ManagedPackage) {
return game_version && loader;
};
const filtered = () => clusters()?.filter(meetsRequirements);
const filtered = createMemo(() => clusters()?.filter(meetsRequirements));
const getSelectedCluster = () => filtered()?.[selected()];
@ -299,44 +314,44 @@ function InstallButton(props: ManagedPackage) {
});
return (
<div class="h-12 flex flex-row">
<Show when={(filtered()?.length || 0) > 0}>
<div class="h-12 flex flex-row">
<Button
buttonStyle="primary"
children={(
<div class="flex flex-1 flex-col items-center justify-center">
<p class="text-xs">Download latest to</p>
<span class="mt-0.5 h-3.5 max-w-38 overflow-x-hidden text-sm font-bold">{filtered?.()?.[selected?.()]?.meta.name || 'Unknown'}</span>
</div>
)}
class="max-w-full flex-1 rounded-r-none!"
disabled={filtered()?.length === 0}
iconLeft={<Download01Icon />}
onClick={download}
/>
<Dropdown
class="w-8"
component={props => (
<Button
buttonStyle="primary"
class="h-full w-full border-l border-white/5 rounded-l-none! px-0!"
iconLeft={<ChevronDownIcon />}
onClick={() => props.setVisible(true)}
/>
)}
disabled={filtered()?.length === 0}
dropdownClass="w-58! right-0"
onChange={setSelected}
selected={selected}
>
<For each={filtered()}>
{cluster => (
<Dropdown.Row>{cluster.meta.name}</Dropdown.Row>
<Button
buttonStyle="primary"
children={(
<div class="flex flex-1 flex-col items-center justify-center">
<p class="text-xs">Download latest to</p>
<span class="mt-0.5 h-3.5 max-w-38 overflow-x-hidden text-sm font-bold">{filtered?.()?.[selected?.()]?.meta.name || 'Unknown'}</span>
</div>
)}
</For>
</Dropdown>
</div>
class="max-w-full flex-1 rounded-r-none!"
iconLeft={<Download01Icon />}
onClick={download}
/>
<Dropdown
class="w-8"
component={props => (
<Button
buttonStyle="primary"
class="h-full w-full border-l border-white/5 rounded-l-none! px-0!"
iconLeft={<ChevronDownIcon />}
onClick={() => props.setVisible(true)}
/>
)}
dropdownClass="w-58! right-0"
onChange={setSelected}
selected={selected}
>
<For each={filtered()}>
{cluster => (
<Dropdown.Row>{cluster.meta.name}</Dropdown.Row>
)}
</For>
</Dropdown>
</div>
</Show>
);
}
@ -346,7 +361,7 @@ function BrowserPackageBody() {
return (
<div class="w-full flex-1 rounded-lg bg-component-bg p-4 px-6">
<Markdown body={context?.body || ''} />
<Markdown body={context?.body?.() || ''} />
</div>
);
}
@ -370,8 +385,8 @@ function BrowserPackageVersions() {
const context = useContext(BrowserPackageContext);
const MAX_ITEMS_PER_PAGE = 20;
const { page, Navigation } = usePagination({
itemsCount: () => context?.versions.length || 0,
const { page, Navigation, reset, options } = usePagination({
itemsCount: () => context?.pkg().versions.length || 0,
itemsPerPage: () => MAX_ITEMS_PER_PAGE,
});
@ -379,20 +394,32 @@ function BrowserPackageVersions() {
if (context === null)
return [];
const newestToOldest = context.versions.toReversed();
const newestToOldest = context.pkg().versions.toReversed();
const start = (page - 1) * MAX_ITEMS_PER_PAGE;
const end = start + MAX_ITEMS_PER_PAGE;
return newestToOldest.slice(start, end);
};
const [versions, { refetch }] = useCommand(context, async () => {
const [versions, { refetch }] = createResource(context, async () => {
if (context === null)
return { error: 'No package context', status: 'error' };
throw new Error('Context is null');
if (context.pkg().versions.length === 0) {
const [data, pagination] = (await tryResult(() => bridge.commands.getAllProviderPackageVersions(context.pkg().provider, context.pkg().id, null, null, page() - 1, MAX_ITEMS_PER_PAGE))) || [];
if (options().itemsCount() !== pagination.totalCount)
reset({
itemsCount: () => pagination.totalCount || 0,
itemsPerPage: () => MAX_ITEMS_PER_PAGE,
});
return data;
}
const list = getVersionsForPage(page());
return await bridge.commands.getProviderPackageVersions(context.provider, list);
return (await tryResult(() => bridge.commands.getProviderPackageVersions(context.pkg().provider, list))).toReversed();
});
let container!: HTMLDivElement;
@ -430,7 +457,7 @@ function BrowserPackageVersions() {
</thead>
<tbody>
<For each={versions()?.reverse()}>
<For each={versions() || []}>
{version => <VersionRow {...version} />}
</For>
</tbody>
@ -444,7 +471,7 @@ function BrowserPackageVersions() {
}
function colorForType(type: string) {
switch (type) {
switch (type.toLowerCase()) {
case 'release':
return 'bg-code-trace';
case 'snapshot':
@ -459,6 +486,59 @@ function colorForType(type: string) {
}
function VersionRow(props: ManagedVersion) {
const [clusters, setClusters] = createSignal<Cluster[]>([]);
const context = useContext(BrowserPackageContext);
async function getClusters() {
const clusters = await tryResult(bridge.commands.getClusters);
return clusters.filter((cluster) => {
const game_version = props.game_versions.includes(cluster.meta.mc_version);
const loader = props.loaders.includes(cluster.meta.loader || 'vanilla');
return game_version && loader;
});
}
const modal = createModal((props) => {
return (
<ChooseClusterModal
clusters={clusters}
onSelected={downloadPackage}
{...props}
/>
);
});
async function downloadPrompted() {
const clusterz = await getClusters();
setClusters(clusterz);
modal.show();
}
async function downloadPackage(cluster: Cluster) {
if (cluster === undefined)
return;
try {
// eslint-disable-next-line solid/reactivity -- lint issue
await tryResult(() => bridge.commands.downloadProviderPackage(
context!.pkg()!.provider,
context!.pkg()!.id,
cluster.uuid,
cluster.meta.mc_version,
cluster.meta.loader || null,
props.id,
));
await bridge.commands.syncClusterPackages(cluster.path || '');
}
catch (err) {
console.error(err);
}
}
return (
<tr class="my-2 bg-page-elevated px-4 [&>td]:py-4">
<td class="rounded-l-lg px-4">
@ -471,8 +551,8 @@ function VersionRow(props: ManagedVersion) {
<td>
<div class="flex flex-col gap-2">
<h3 class="text-lg">{props.version_id}</h3>
<p class="text-wrap text-sm">{props.name}</p>
<h3 class="text-lg">{props.version_display}</h3>
<p class="text-wrap text-sm">{props.name === props.version_display ? props.files[0]?.file_name : props.name}</p>
</div>
</td>
@ -508,6 +588,7 @@ function VersionRow(props: ManagedVersion) {
<Button
buttonStyle="iconSecondary"
children={<Download01Icon />}
onClick={downloadPrompted}
/>
</td>

View file

@ -93,10 +93,10 @@ function BrowserCategories() {
<div class="flex flex-col gap-y-6">
<div class="flex flex-col gap-y-2">
<h6 class="my-1">Categories</h6>
<For each={browserCategories.byPackageType(browser.packageType())}>
<For each={browserCategories.byPackageType(browser.packageType(), browser.searchQuery().provider)}>
{category => (
<p
class={`text-md capitalize text-fg-primary hover:text-fg-primary-hover ${isEnabled(category.id) ? 'text-opacity-100! hover:text-opacity-90!' : 'text-opacity-60! hover:text-opacity-70!'}`}
class={`text-md capitalize text-fg-primary hover:text-fg-primary-hover line-height-snug ${isEnabled(category.id) ? 'text-opacity-100! hover:text-opacity-90!' : 'text-opacity-60! hover:text-opacity-70!'}`}
onClick={() => toggleCategory(category.id)}
>
{category.display}
@ -127,10 +127,13 @@ function BrowserSidebar() {
<div class="flex flex-col gap-y-1">
<h6 class="my-1">Provider</h6>
<Dropdown
onChange={index => controller.setSearchQuery(prev => ({
...prev,
provider: PROVIDERS[index] || 'Modrinth',
}))}
onChange={(index) => {
controller.setSearchQuery(prev => ({
...prev,
provider: PROVIDERS[index] || 'Modrinth',
}));
controller.search();
}}
selected={() => PROVIDERS.indexOf(controller.searchQuery().provider)}
>
<For each={PROVIDERS}>

View file

@ -9,7 +9,7 @@ import { BrowserContent } from './BrowserRoot';
function BrowserSearch() {
const browser = useBrowser();
const [results, { refetch }] = useCommand(() => bridge.commands.searchProviderPackages('Modrinth', browser.searchQuery()));
const [results, { refetch }] = useCommand(() => bridge.commands.searchProviderPackages(browser.searchQuery().provider, browser.searchQuery()));
const paginationOptions = (): PaginationOptions => ({
itemsCount: () => results()?.total || 0,
@ -39,14 +39,13 @@ function BrowserSearch() {
<Spinner.Suspense>
<Show when={results() !== undefined}>
<SearchResultsContainer
category="test"
header={(
<div class="w-full flex flex-row items-end justify-between">
<h5 class="ml-2">Results</h5>
<pagination.Navigation />
</div>
)}
provider="Modrinth"
provider={browser.searchQuery().provider}
results={results()!.results}
/>
</Show>

View file

@ -6,7 +6,6 @@ import Button from '~ui/components/base/Button';
import TextField from '~ui/components/base/TextField';
import ScrollableContainer from '~ui/components/ScrollableContainer';
import Sidebar from '~ui/components/Sidebar';
import Spinner from '~ui/components/Spinner';
import useBrowser from '~ui/hooks/useBrowser';
import useClusterContext from '~ui/hooks/useCluster';
import useCommand, { tryResult } from '~ui/hooks/useCommand';
@ -39,19 +38,10 @@ function ClusterMods() {
}
return formatted;
}, {
initialValue: new Map<Providers, ManagedPackage[]>(),
});
function getManagedData(mod: Package): ManagedPackage | undefined {
const meta = mod.meta;
if (meta.type === 'managed') {
const provider = managedMods()?.get(meta.provider);
if (provider && provider.length > 0)
return provider.find(pkg => pkg.id === meta.package_id);
}
return undefined;
}
// Mods to display, should be a clone of the mods array but sorted
const [displayedMods, setDisplayedMods] = createSignal<Package[]>([]);
@ -125,24 +115,19 @@ function ClusterMods() {
</div>
<ScrollableContainer>
<Spinner.Suspense>
<Show when={managedMods.loading === false}>
<Switch>
<Match when={displayedMods().length > 0}>
<For each={displayedMods()}>
{(mod) => {
const managed = getManagedData(mod);
return <ModEntry managed={managed} pkg={mod} refetch={refetch} />;
}}
</For>
</Match>
<Switch>
<Match when={displayedMods().length > 0}>
<For each={displayedMods()}>
{mod => (
<ModEntry managedMods={managedMods()} pkg={mod} refetch={refetch} />
)}
</For>
</Match>
<Match when={displayedMods().length === 0}>
<p class="my-4 text-center text-2lg">No mods were found</p>
</Match>
</Switch>
</Show>
</Spinner.Suspense>
<Match when={displayedMods().length === 0}>
<p class="my-4 text-center text-2lg">No mods were found</p>
</Match>
</Switch>
</ScrollableContainer>
</Sidebar.Page>
);
@ -153,13 +138,23 @@ export default ClusterMods;
interface ModEntryProps {
pkg: Package;
refetch: () => void;
managed?: ManagedPackage | undefined;
managedMods?: Map<Providers, ManagedPackage[]>;
};
function ModEntry(props: ModEntryProps) {
const [cluster] = useClusterContext();
const browser = useBrowser();
const managed = () => {
const pkg = props.pkg;
if (props.managedMods && pkg.meta.type === 'managed') {
const package_id = pkg.meta.package_id;
return props.managedMods.get(pkg.meta.provider)?.find(p => p.id === package_id);
}
return undefined;
};
const name = () => {
if (props.pkg.meta.type === 'unknown')
return props.pkg.file_name;
@ -191,17 +186,19 @@ function ModEntry(props: ModEntryProps) {
const icon = () => {
if (props.pkg.meta.type === 'managed')
return props.managed?.icon_url;
return managed()?.icon_url;
return undefined;
};
function onClick(e: MouseEvent) {
if (props.managed) {
const mngd = managed();
if (mngd) {
e.preventDefault();
e.stopPropagation();
browser.displayPackage(props.managed.id, props.managed.provider);
browser.displayPackage(mngd.id, mngd.provider);
}
}

View file

@ -7,74 +7,11 @@ export interface CategoryItem {
id: string;
}
type ModCategories = [
'adventure',
'library',
'equipment',
'decoration',
'food',
'magic',
'performance',
'storage',
'technology',
'utility',
'worldgen',
];
const modMapping: Record<Providers, { [key in ModCategories[number]]: CategoryItem }> = {
Modrinth: {
adventure: {
display: 'Adventure',
id: 'adventure',
},
library: {
display: 'Library',
id: 'library',
},
decoration: {
display: 'Decoration',
id: 'decoration',
},
equipment: {
display: 'Equipment',
id: 'equipment',
},
food: {
display: 'Food',
id: 'food',
},
magic: {
display: 'Magic',
id: 'magic',
},
performance: {
display: 'Performance',
id: 'optimization',
},
storage: {
display: 'Storage',
id: 'storage',
},
technology: {
display: 'Technology',
id: 'technology',
},
utility: {
display: 'Utility',
id: 'utility',
},
worldgen: {
display: 'World Generation',
id: 'worldgen',
},
},
};
export const browserCategories = {
byPackageType(packageType: PackageType): CategoryItem[] {
byPackageType(packageType: PackageType, provider: Providers = 'Modrinth'): CategoryItem[] {
switch (packageType) {
case 'mod':
return this.mod();
return this.mod(provider);
}
return [];
@ -84,3 +21,76 @@ export const browserCategories = {
return Object.values(modMapping[provider]);
},
};
const modMapping: Record<Providers, CategoryItem[]> = {
Curseforge: [
{ display: 'Adventure', id: '422' },
{ display: 'API and Library', id: '421' },
{ display: 'Armor and Tools', id: '434' },
{ display: 'Biomes', id: '407' },
{ display: 'Bug Fixes', id: '6821' },
{ display: 'Cosmetic', id: '424' },
{ display: 'Dimensions', id: '410' },
{ display: 'Education', id: '5299' },
{ display: 'Energy', id: '417' },
{ display: 'Farming', id: '416' },
{ display: 'Food', id: '436' },
{ display: 'Forestry', id: '433' },
{ display: 'Magic', id: '419' },
{ display: 'Miscellaneous', id: '425' },
{ display: 'Mobs', id: '411' },
{ display: 'Ores and Resources', id: '408' },
{ display: 'Performance', id: '6814' },
{ display: 'Player Transport', id: '414' },
{ display: 'Redstone', id: '4558' },
{ display: 'Storage', id: '420' },
{ display: 'Structures', id: '409' },
{ display: 'Technology', id: '412' },
{ display: 'Utility & QoL', id: '5191' },
{ display: 'World Generation', id: '406' },
// { display: 'Map and Information', id: '423' },
// { display: 'Server Utility', id: '435' },
// { display: 'Twitch Integration', id: '4671' },
// { display: 'Create', id: '6484' },
// { display: 'Buildcraft', id: '432' },
// { display: 'Industrial Craft', id: '429' },
// { display: 'KubeJS', id: '5314' },
// { display: 'Genetics', id: '418' },
// { display: 'Blood Magic', id: '4485' },
// { display: 'Automation', id: '4843' },
// { display: 'CraftTweaker', id: '4773' },
// { display: 'Addons', id: '426' },
// { display: 'Energy, Fluid, and Item Transport', id: '415' },
// { display: 'Thermal Expansion', id: '427' },
// { display: 'Thaumcraft', id: '430' },
// { display: 'Processing', id: '413' },
// { display: 'Tinker\'s Construct', id: '428' },
// { display: 'Skyblock', id: '6145' },
// { display: 'Galacticraft', id: '5232' },
// { display: 'Applied Energistics 2', id: '4545' },
// { display: 'Integrated Dynamics', id: '6954' },
// { display: 'MCreator', id: '4906' },
],
Modrinth: [
{ display: 'Adventure', id: 'adventure' },
{ display: 'Cursed', id: 'cursed' },
{ display: 'Decoration', id: 'decoration' },
{ display: 'Economy', id: 'economy' },
{ display: 'Equipment', id: 'equipment' },
{ display: 'Food', id: 'food' },
{ display: 'Game Mechanics', id: 'game-mechanics' },
{ display: 'Library', id: 'library' },
{ display: 'Magic', id: 'magic' },
{ display: 'Management', id: 'management' },
{ display: 'Minigame', id: 'minigame' },
{ display: 'Mobs', id: 'mobs' },
{ display: 'Optimization', id: 'optimization' },
{ display: 'Social', id: 'social' },
{ display: 'Storage', id: 'storage' },
{ display: 'Technology', id: 'technology' },
{ display: 'Transportation', id: 'transportation' },
{ display: 'Utility', id: 'utility' },
{ display: 'World Generation', id: 'worldgen' },
],
};

View file

@ -1,4 +1,4 @@
import type { Cluster, ImportType, License, Loader, PackageType, Providers, VersionType } from '@onelauncher/client/bindings';
import type { Cluster, ImportType, License, Loader, ManagedPackage, PackageType, Providers, VersionType } from '@onelauncher/client/bindings';
import { DurationFormat } from '@formatjs/intl-durationformat';
import { open } from '@tauri-apps/plugin-shell';
@ -160,15 +160,26 @@ export function getLicenseUrl(licenseId: string | License | null | undefined): s
return `https://spdx.org/licenses/${id}.html`;
}
export function getPackageUrl(provider: Providers, id: string, package_type: PackageType = 'mod'): string {
const mapping: Record<Providers, string> = {
Modrinth: `https://modrinth.com/${package_type}/${id}`,
export function getPackageUrl(pkg: ManagedPackage): string {
const mapping: Record<Providers, () => string> = {
Modrinth: () => `https://modrinth.com/${pkg.package_type}/${pkg.id}`,
Curseforge: () => {
const packageTypeMapping: Record<PackageType, string> = {
mod: 'mc-mods',
shaderpack: 'shaders',
resourcepack: 'texture-packs',
datapack: 'data-packs',
modpack: 'modpacks',
};
return `https://www.curseforge.com/minecraft/${packageTypeMapping[pkg.package_type]}/${pkg.main}`;
},
};
return mapping[provider];
return mapping[pkg.provider]();
}
export const LOADERS: Loader[] = ['vanilla', 'fabric', 'forge', 'neoforge', 'quilt'] as const;
export const PROVIDERS: Providers[] = ['Modrinth'] as const;
export const PROVIDERS: Providers[] = ['Modrinth', 'Curseforge'] as const;
export const PACKAGE_TYPES: PackageType[] = ['mod', 'resourcepack', 'datapack', 'shaderpack'] as const;
export const LAUNCHER_IMPORT_TYPES: ImportType[] = ['PrismLauncher', 'Curseforge', 'Modrinth', 'ATLauncher', 'GDLauncher', 'FTBLauncher', 'MultiMC', 'TLauncher', 'Technic'] as const;

View file

@ -33,6 +33,14 @@ export default defineConfig(async ({ mode }) => {
emptyOutDir: false,
},
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
},
},
},
clearScreen: false,
server: {
port: 8001,

View file

@ -1,15 +1,23 @@
use onelauncher::data::PackageType;
use onelauncher::package::content;
use onelauncher::package::content::Providers;
use onelauncher::{Result, State};
#[tokio::main]
async fn main() -> Result<()> {
let _ = State::get().await?;
let provider = content::Providers::Modrinth;
search(Providers::Modrinth).await?;
search(Providers::Curseforge).await?;
Ok(())
}
async fn search(provider: Providers) -> Result<()> {
println!("Searching for packages from {}...", provider.name());
let results = provider
.search(
Some("chatting".to_string()),
Some("Evergreen HUD".to_string()),
Some(5),
Some(0),
None,

View file

@ -4,7 +4,7 @@
"version": "1.0.0-alpha.1",
"private": true,
"engineStrict": true,
"packageManager": "pnpm@9.11.0",
"packageManager": "pnpm@9.12.2",
"description": "Next-generation open source Minecraft launcher and tools for interacting with Polyfrost",
"license": "GPL-3.0-only",
"homepage": "https://polyfrost.org",

View file

@ -39,6 +39,7 @@ tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_ini = { workspace = true }
serde_repr = { workspace = true }
paste = { workspace = true }
futures = { workspace = true }
zip = { workspace = true }
@ -79,6 +80,9 @@ specta = { workspace = true, optional = true }
# features[cli]
indicatif = { workspace = true, optional = true }
[build-dependencies]
dotenvy = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = { workspace = true }

40
packages/core/build.rs Normal file
View file

@ -0,0 +1,40 @@
use std::path::PathBuf;
fn main() {
let paths = vec![get_root_workspace().join(".env"), PathBuf::from(".env")];
for path in paths {
if path.exists() {
load_from_path(path);
}
}
}
fn get_root_workspace() -> PathBuf {
let output: Vec<u8> = std::process::Command::new(env!("CARGO"))
.arg("locate-project")
.arg("--workspace")
.arg("--message-format=plain")
.output()
.unwrap()
.stdout;
let root_workspace = String::from_utf8(output).unwrap();
let mut root_workspace = PathBuf::from(root_workspace.trim());
if root_workspace.is_file() {
root_workspace.pop();
}
root_workspace
}
fn load_from_path(path: PathBuf) {
let vars = dotenvy::EnvLoader::with_path(path)
.load()
.expect("failed to find .env file");
for (key, value) in vars {
println!("cargo:rustc-env={key}={value}");
}
}

View file

@ -28,12 +28,18 @@ pub async fn find_managed_version(
&package.id,
game_version.as_ref().map(|v| vec![v.to_owned()]).clone(),
loader.map(|l| vec![l]).clone(),
None,
None,
)
.await?;
Ok(versions
Ok(versions.0
.iter()
.find(|v| {
if package_version.is_some() {
return v.id == *package_version.as_ref().unwrap();
}
let check_game_version = game_version
.as_ref()
.is_some_and(|gv| v.game_versions.iter().any(|gv2| *gv2 == *gv));
@ -42,11 +48,7 @@ pub async fn find_managed_version(
.as_ref()
.is_some_and(|loader| v.loaders.iter().any(|l| *l == *loader));
let check_package_version = package_version
.as_ref()
.map_or(true, |pv| pv == &v.version_id);
check_game_version && check_loader && check_package_version
check_game_version && check_loader
})
.ok_or_else(|| anyhow::anyhow!("no matching version found"))
.cloned()?)

View file

@ -0,0 +1,679 @@
use std::collections::HashMap;
use bytes::Bytes;
use chrono::{DateTime, Utc};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
use url::Url;
use crate::data::{Loader, ManagedPackage, ManagedUser, ManagedVersion, PackageType};
use crate::store::{ManagedVersionFile, ManagedVersionReleaseType, ProviderSearchResults, SearchResult};
use crate::utils::{http, pagination::Pagination};
use crate::{Result, State};
/// Mapping of Curseforge "classes" to their respective category ids
#[derive(Debug, Default, Serialize_repr, Deserialize_repr)]
#[repr(u32)]
pub enum CFPackageType {
#[default]
Mods = 6,
ModPack = 4471,
DataPack = 6945,
ResourcePack = 12,
ShaderPack = 6552,
}
impl From<CFPackageType> for PackageType {
fn from(package_type: CFPackageType) -> Self {
match package_type {
CFPackageType::Mods => PackageType::Mod,
CFPackageType::ModPack => PackageType::ModPack,
CFPackageType::DataPack => PackageType::DataPack,
CFPackageType::ResourcePack => PackageType::ResourcePack,
CFPackageType::ShaderPack => PackageType::ShaderPack,
}
}
}
impl From<PackageType> for CFPackageType {
fn from(package_type: PackageType) -> Self {
match package_type {
PackageType::Mod => CFPackageType::Mods,
PackageType::ModPack => CFPackageType::ModPack,
PackageType::DataPack => CFPackageType::DataPack,
PackageType::ResourcePack => CFPackageType::ResourcePack,
PackageType::ShaderPack => CFPackageType::ShaderPack,
}
}
}
/// Mapping of Curseforge supported loaders to their respective id
#[derive(Debug, Default, Serialize_repr, Deserialize_repr)]
#[repr(u8)]
pub enum CurseforgeLoader {
#[default]
Any = 0,
Forge = 1,
Fabric = 4,
Quilt = 5,
NeoForge = 6,
}
impl From<String> for CurseforgeLoader {
fn from(loader: String) -> Self {
match loader.to_lowercase().as_str() {
"forge" => CurseforgeLoader::Forge,
"fabric" => CurseforgeLoader::Fabric,
"quilt" => CurseforgeLoader::Quilt,
"neoforge" => CurseforgeLoader::NeoForge,
_ => CurseforgeLoader::Any,
}
}
}
impl From<Loader> for CurseforgeLoader {
fn from(loader: Loader) -> Self {
match loader {
Loader::Forge => CurseforgeLoader::Forge,
Loader::Fabric => CurseforgeLoader::Fabric,
Loader::Quilt => CurseforgeLoader::Quilt,
Loader::NeoForge => CurseforgeLoader::NeoForge,
_ => CurseforgeLoader::Any,
}
}
}
impl Into<Loader> for CurseforgeLoader {
fn into(self) -> Loader {
match self {
CurseforgeLoader::Forge => Loader::Forge,
CurseforgeLoader::Fabric => Loader::Fabric,
CurseforgeLoader::Quilt => Loader::Quilt,
CurseforgeLoader::NeoForge => Loader::NeoForge,
_ => Loader::Vanilla,
}
}
}
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseforgePackage {
pub id: u32,
pub game_id: u32,
#[serde(alias = "classId")]
pub package_type: CFPackageType,
pub name: String,
pub slug: String,
pub links: Links,
pub summary: String,
// pub status: u32,
pub download_count: u64,
#[serde(default)]
pub is_featured: bool,
// pub primary_category_id: u32,
pub categories: Vec<Category>,
pub authors: Vec<Author>,
pub logo: Option<Logo>,
// pub screenshots: Vec<Screenshot>,
// pub main_file_id: u32,
// pub latest_files: Vec<LatestFile>,
// pub latest_files_indexes: Vec<LatestFilesIndex>,
// pub latest_early_access_files_indexes: Vec<LatestEarlyAccessFilesIndex>,
pub date_created: DateTime<Utc>,
pub date_modified: DateTime<Utc>,
pub date_released: DateTime<Utc>,
// #[serde(default)]
// pub allow_mod_distribution: bool,
// pub game_popularity_rank: u32,
// pub is_available: bool,
pub thumbs_up_count: u32,
pub rating: Option<f32>,
}
impl From<CurseforgePackage> for ManagedPackage {
fn from(package: CurseforgePackage) -> Self {
ManagedPackage {
id: package.id.to_string(),
title: package.name,
provider: super::Providers::Curseforge,
package_type: package.package_type.into(),
description: package.summary,
body: crate::store::PackageBody::Url(format!("/v1/mods/{}/description", package.id)),
main: package.slug,
versions: vec![],
game_versions: vec![],
loaders: vec![],
icon_url: package.logo.and_then(|l| Some(l.url)),
created: package.date_created,
updated: package.date_modified,
client: crate::store::PackageSide::Unknown,
server: crate::store::PackageSide::Unknown,
downloads: package.download_count,
followers: package.rating.unwrap_or(0.0) as u32,
categories: vec![], // TODO
optional_categories: None,
license: None,
author: crate::store::Author::Users(
package
.authors
.into_iter()
.map(|a| ManagedUser {
id: a.id.to_string(),
username: a.name,
url: Some(a.url),
is_organization_user: false,
avatar_url: None,
bio: None,
role: None,
})
.collect(),
),
is_archived: false,
}
}
}
impl Into<SearchResult> for CurseforgePackage {
fn into(self) -> SearchResult {
SearchResult {
title: self.name,
slug: self.slug,
project_id: self.id.to_string(),
author: self
.authors
.first()
.and_then(|a| Some(a.name.clone()))
.unwrap_or(String::from("Unknown")),
description: self.summary,
client_side: crate::store::PackageSide::Unknown,
server_side: crate::store::PackageSide::Unknown,
project_type: self.package_type.into(),
downloads: self.download_count,
icon_url: self.logo.map_or(String::new(), |l| l.url),
categories: vec![], // TODO
display_categories: vec![],
versions: vec![],
follows: self.thumbs_up_count,
date_created: self.date_created,
date_modified: self.date_modified,
}
}
}
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Links {
pub website_url: Option<String>,
pub wiki_url: Option<String>,
pub issues_url: Option<String>,
pub source_url: Option<String>,
}
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Category {
pub id: u32,
pub game_id: u32,
pub name: String,
pub slug: String,
pub url: String,
pub icon_url: String,
pub date_modified: String,
// pub parent_category_id: u32,
// pub display_index: u32,
}
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Author {
pub id: u32,
pub name: String,
pub url: String,
}
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Logo {
pub id: u32,
pub mod_id: u32,
pub title: String,
pub description: String,
pub thumbnail_url: String,
pub url: String,
}
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Screenshot {
pub id: u32,
pub mod_id: u32,
pub title: String,
pub description: String,
pub thumbnail_url: String,
pub url: String,
}
#[derive(Default, Serialize_repr, Deserialize_repr, Clone, Debug)]
#[repr(u8)]
pub enum ModFileReleaseType {
#[default]
Release = 1,
Beta = 2,
Alpha = 3,
}
impl Into<ManagedVersionReleaseType> for ModFileReleaseType {
fn into(self) -> ManagedVersionReleaseType {
match self {
ModFileReleaseType::Release => ManagedVersionReleaseType::Release,
ModFileReleaseType::Beta => ManagedVersionReleaseType::Beta,
ModFileReleaseType::Alpha => ManagedVersionReleaseType::Alpha,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModFile {
pub id: u32,
pub game_id: u32,
pub mod_id: u32,
#[serde(default)]
pub is_available: bool,
pub display_name: String,
pub file_name: String,
pub release_type: ModFileReleaseType,
// pub file_status: u32,
pub hashes: Vec<Hash>,
pub file_date: DateTime<Utc>,
pub file_length: u64,
pub download_count: u32,
pub file_size_on_disk: Option<u64>,
pub download_url: Option<String>,
pub game_versions: Vec<String>,
pub sortable_game_versions: Vec<SortableGameVersion>,
pub dependencies: Vec<Dependency>,
// pub expose_as_alternative: bool,
// pub parent_project_file_id: u32,
// pub alternate_file_id: u32,
// pub is_server_pack: bool,
// pub server_pack_file_id: u32,
// pub is_early_access_content: bool,
// pub early_access_end_date: String,
// pub file_fingerprint: u32,
// pub modules: Vec<Module>,
}
impl Into<ManagedVersion> for ModFile {
fn into(self) -> ManagedVersion {
let mut hashes = HashMap::new();
for hash in self.hashes {
hashes.insert(hash.algo.name(), hash.value);
}
let mut game_versions = Vec::new();
let mut loader = CurseforgeLoader::Any;
for version in self.sortable_game_versions {
if version.game_version.is_empty() {
// This is most likely a loader
loader = CurseforgeLoader::from(version.game_version_name);
continue;
}
game_versions.push(version.game_version);
}
let mut files = Vec::new();
if let Some(download_url) = self.download_url {
files.push(ManagedVersionFile {
url: download_url,
file_name: self.file_name,
primary: true,
size: self.file_size_on_disk.unwrap_or(self.file_length),
file_type: Some(crate::store::PackageFile::RequiredPack),
hashes,
});
}
ManagedVersion {
package_id: self.mod_id.to_string(),
id: self.id.to_string(),
name: self.display_name.clone(),
author: String::new(),
loaders: vec![Into::<Loader>::into(loader)],
changelog: String::new(),
changelog_url: None,
deps: vec![],
downloads: self.download_count,
featured: false,
is_available: self.is_available && files.len() > 0,
files,
game_versions: game_versions,
published: self.file_date,
version_display: self.display_name,
version_type: self.release_type.into(),
}
}
}
#[derive(Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)]
#[repr(u8)]
pub enum HashAlgorithm {
Sha1 = 1,
MD5 = 2,
}
impl HashAlgorithm {
pub fn name(&self) -> String {
match self {
Self::Sha1 => "sha1",
Self::MD5 => "md5",
}.to_string()
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Hash {
pub value: String,
pub algo: HashAlgorithm,
}
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SortableGameVersion {
pub game_version_name: String,
pub game_version_padded: String,
pub game_version: String,
pub game_version_release_date: String,
pub game_version_type_id: u32,
}
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Dependency {
pub mod_id: u32,
pub relation_type: u32,
}
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Module {
pub name: String,
pub fingerprint: u32,
}
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModFilesIndex {
pub game_version: String,
pub file_id: u32,
pub filename: String,
pub release_type: u32,
pub game_version_type_id: u32,
#[serde(default)]
pub mod_loader: CurseforgeLoader,
}
// https://docs.curseforge.com/rest-api/?shell#tocS_ModsSearchSortField
#[allow(dead_code)]
enum SearchSort {
Featured = 1,
Popularity = 2,
LastUpdated = 3,
Name = 4,
Author = 5,
TotalDownloads = 6,
Rating = 12,
}
#[derive(Default, Debug, Serialize, Deserialize)]
struct CFData<T> {
data: T,
}
#[derive(Default, Debug, Serialize, Deserialize)]
struct CFPaginatedData<T> {
pagination: Pagination,
data: T,
}
pub async fn fetch_advanced<F: FnOnce(&mut Url)>(
method: Method,
url: &str,
url_builder: Option<F>,
headers: Option<HashMap<&str, &str>>,
json_body: Option<serde_json::Value>,
) -> Result<Bytes> {
// TODO: Get the API key from settings, fallback to constant, and error if missing
let key = crate::constants::CURSEFORGE_API_KEY
.ok_or(anyhow::anyhow!("missing curseforge api key"))?;
let mut headers = headers.unwrap_or(HashMap::<&str, &str>::new());
headers.insert("x-api-key", key);
let url =
&mut Url::parse(format!("{}{}", crate::constants::CURSEFORGE_API_URL, url).as_str())?;
if let Some(url_builder) = url_builder {
url_builder(url);
}
http::fetch_advanced(
method,
url.as_str(),
None,
json_body,
Some(headers),
None,
&State::get().await?.fetch_semaphore,
)
.await
}
pub async fn fetch_url_builder<F: FnOnce(&mut Url)>(
url: &str,
url_builder: Option<F>,
) -> Result<Bytes> {
fetch_advanced(Method::GET, url, url_builder, None, None).await
}
async fn fetch(url: &str) -> Result<Bytes> {
fetch_url_builder(url, None::<fn(&mut Url)>).await
}
#[tracing::instrument(skip_all)]
pub async fn search(
query: Option<String>,
limit: Option<u8>,
offset: Option<u32>,
game_versions: Option<Vec<String>>,
_categories: Option<Vec<String>>,
loaders: Option<Vec<Loader>>,
package_types: Option<Vec<PackageType>>,
) -> Result<ProviderSearchResults> {
type CFSearchResults = CFPaginatedData<Vec<CurseforgePackage>>;
let results: CFSearchResults = serde_json::from_slice(
&fetch_url_builder(
"/v1/mods/search",
Some(Box::new(move |url: &mut Url| {
let params = &mut url.query_pairs_mut();
params.append_pair(
"gameId",
crate::constants::CURSEFORGE_GAME_ID.to_string().as_str(),
);
params.append_pair("pageSize", limit.unwrap_or(10).to_string().as_str());
params.append_pair("index", offset.unwrap_or(0).to_string().as_str());
params.append_pair(
"sortField",
(SearchSort::Popularity as u8).to_string().as_str(),
);
params.append_pair("sortOrder", "desc");
if let Some(query) = query {
params.append_pair("searchFilter", query.as_str());
}
if let Some(game_versions) = game_versions {
// Need to create a list e.g. ["1.16.5", "1.17.1"]
params.append_pair(
"gameVersions",
format!(
"[{}]",
game_versions
.into_iter()
.map(|v| format!("\"{}\"", v))
.collect::<Vec<String>>()
.join(",")
)
.as_str(),
);
}
if let Some(loaders) = loaders {
params.append_pair(
"modLoaderTypes",
format!(
"[{}]",
loaders
.into_iter()
.map(|l| (CurseforgeLoader::from(l) as u32).to_string())
.collect::<Vec<String>>()
.join(",")
)
.as_str(),
);
}
if let Some(package_types) = package_types {
if package_types.len() == 0 {
return;
}
if package_types.len() > 1 {
tracing::warn!(
"curseforge provider does not support multiple package types"
);
}
let package_type = package_types.first().unwrap();
params.append_pair(
"classId",
(CFPackageType::from(*package_type) as u32)
.to_string()
.as_str(),
);
}
})),
)
.await?,
)?;
Ok(ProviderSearchResults {
total: results.pagination.total_count,
results: results
.data
.into_iter()
.map(Into::into)
.collect::<Vec<SearchResult>>(),
provider: crate::package::content::Providers::Curseforge,
})
}
pub async fn get(id: u32) -> Result<CurseforgePackage> {
Ok(
serde_json::from_slice::<CFData<_>>(&fetch(format!("/v1/mods/{}", id).as_str()).await?)?
.data,
)
}
pub async fn get_multiple(ids: &[u32]) -> Result<Vec<CurseforgePackage>> {
let mut map = serde_json::Map::new();
map.insert("modIds".to_string(), ids.to_vec().into());
let json_body = serde_json::Value::Object(map);
Ok(
serde_json::from_slice::<CFData<_>>(
&fetch_advanced(
Method::POST,
"/v1/mods",
None::<fn(&mut Url)>,
None,
Some(json_body),
).await?)?
.data,
)
}
pub async fn get_package_body(url: String) -> Result<String> {
Ok(serde_json::from_slice::<CFData<_>>(&fetch(&url).await?)?.data)
}
pub async fn get_all_versions(
project_id: &str,
game_versions: Option<Vec<String>>,
loaders: Option<Vec<Loader>>,
page: Option<u32>,
page_size: Option<u16>,
) -> Result<(Vec<ModFile>, Pagination)> {
let data: CFPaginatedData<Vec<ModFile>> = serde_json::from_slice::<CFPaginatedData<_>>(
&fetch_url_builder(
format!("/v1/mods/{project_id}/files").as_str(),
Some(Box::new(move |url: &mut Url| {
if let Some(game_versions) = game_versions {
if let Some(version) = game_versions.first() {
url.query_pairs_mut().append_pair("gameVersion", version);
}
}
if let Some(loader) = loaders {
if let Some(loader) = loader.first() {
url.query_pairs_mut().append_pair(
"modLoaderType",
(CurseforgeLoader::from(*loader) as u8).to_string().as_str(),
);
}
}
let page = page.unwrap_or(0);
url.query_pairs_mut().append_pair("index", page.to_string().as_str());
let page_size = page_size.unwrap_or(10);
url.query_pairs_mut().append_pair("pageSize", page_size.to_string().as_str());
})),
)
.await?,
)?;
Ok((data.data, data.pagination))
}
pub async fn get_versions(
versions: Vec<String>,
) -> Result<Vec<ManagedVersion>> {
let mut map = serde_json::Map::new();
map.insert("fileIds".to_string(), versions.iter().map(|v| v.parse::<u32>().unwrap()).collect::<Vec<u32>>().into());
let json_body = serde_json::Value::Object(map);
Ok(serde_json::from_slice::<CFData<_>>(
&fetch_advanced(
Method::POST,
format!("/v1/mods/files").as_str(),
None::<fn(&mut Url)>,
None,
Some(json_body)
)
.await?,
)?
.data)
}

View file

@ -7,9 +7,11 @@ use serde::{Deserialize, Serialize};
use crate::data::{Loader, ManagedPackage, ManagedUser, ManagedVersion, PackageType};
use crate::package::content::modrinth::FacetBuilder;
use crate::store::{Author, ProviderSearchResults};
use crate::store::{Author, PackageBody, ProviderSearchResults};
use crate::utils::pagination::Pagination;
use crate::Result;
mod curseforge;
mod modrinth;
/// Providers for content packages
@ -17,6 +19,7 @@ mod modrinth;
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
pub enum Providers {
Modrinth,
Curseforge,
}
impl std::fmt::Display for Providers {
@ -31,6 +34,7 @@ impl Providers {
pub const fn name(&self) -> &str {
match self {
Self::Modrinth => "Modrinth",
Self::Curseforge => "Curseforge",
}
}
@ -39,6 +43,7 @@ impl Providers {
pub const fn url(&self) -> &str {
match self {
Self::Modrinth => "https://modrinth.com",
Self::Curseforge => "https://curseforge.com",
}
}
@ -55,94 +60,84 @@ impl Providers {
open_source: Option<bool>,
) -> Result<ProviderSearchResults> {
match self {
Self::Modrinth => modrinth::search(
query,
limit,
offset,
Some(|mut builder: FacetBuilder| {
if let Some(categories) = categories {
for category in categories {
builder.and(Facet(
"categories".to_string(),
FacetOperation::Eq,
category,
));
}
}
if let Some(game_versions) = game_versions {
for version in game_versions {
builder.and(Facet("versions".to_string(), FacetOperation::Eq, version));
}
}
if let Some(package_types) = package_types {
if package_types.contains(&PackageType::Mod)
|| package_types.contains(&PackageType::ModPack)
{
if let Some(loaders) = loaders {
for loader in loaders {
builder.and(Facet(
"categories".to_string(),
FacetOperation::Eq,
loader.to_string(),
));
}
}
}
for package_type in package_types {
builder.and(Facet(
"project_types".to_string(),
FacetOperation::Eq,
package_type.get_name().to_string(),
));
}
}
if let Some(open_source) = open_source {
builder.and(Facet(
"open_source".to_string(),
FacetOperation::Eq,
open_source.to_string(),
));
}
// builder.and(Facet("client_side".to_string(), FacetOperation::Eq, "")) // TODO: Possibly make this client_side = required
builder.build()
}),
),
Self::Modrinth => {
modrinth::search(
query,
limit,
offset,
Some(|mut builder| {
build_facets(
&mut builder,
categories,
game_versions,
loaders,
package_types,
open_source,
)
}),
)
.await
}
Self::Curseforge => {
curseforge::search(
query,
limit,
offset,
game_versions,
categories,
loaders,
package_types,
)
.await
}
}
.await
}
pub async fn list(&self) -> Result<Vec<ManagedPackage>> {
Ok(match self {
Self::Modrinth => modrinth::list(),
}
.await?
.into_iter()
.map(Into::into)
.collect())
}
pub async fn get(&self, slug_or_id: &str) -> Result<ManagedPackage> {
Ok(match self {
Self::Modrinth => modrinth::get(slug_or_id),
}
.await?
.into())
Self::Modrinth => modrinth::get(slug_or_id).await?.into(),
Self::Curseforge => curseforge::get(
slug_or_id
.parse::<u32>()
.map_err(|err| anyhow::anyhow!(err))?,
)
.await?
.into(),
})
}
pub async fn get_multiple(&self, slug_or_ids: &[String]) -> Result<Vec<ManagedPackage>> {
Ok(match self {
Self::Modrinth => modrinth::get_multiple(slug_or_ids),
}
.await?
.into_iter()
.map(Into::into)
.collect())
Self::Modrinth => {
if slug_or_ids.len() <= 0 {
return Ok(vec![]);
}
modrinth::get_multiple(slug_or_ids)
.await?
.into_iter()
.map(Into::into)
.collect()
},
Self::Curseforge => {
let parsed_ids = slug_or_ids
.iter()
.filter_map(|id| id.parse::<u32>().ok())
.collect::<Vec<u32>>();
if parsed_ids.len() <= 0 {
return Ok(vec![]);
}
let parsed_ids = parsed_ids.as_slice();
curseforge::get_multiple(parsed_ids)
.await?
.into_iter()
.map(Into::into)
.collect()
},
})
}
pub async fn get_all_versions(
@ -150,38 +145,124 @@ impl Providers {
project_id: &str,
game_versions: Option<Vec<String>>,
loaders: Option<Vec<Loader>>,
) -> Result<Vec<ManagedVersion>> {
page: Option<u32>,
page_size: Option<u16>,
) -> Result<(Vec<ManagedVersion>, Pagination)> {
Ok(match self {
Self::Modrinth => modrinth::get_all_versions(project_id, game_versions, loaders),
}
.await?
.into_iter()
.map(Into::into)
.collect())
Self::Modrinth => {
let data = modrinth::get_all_versions(project_id, game_versions, loaders, page, page_size).await?;
(data.0.into_iter().map(Into::into).collect(), data.1)
}
Self::Curseforge => {
let data = curseforge::get_all_versions(project_id, game_versions, loaders, page, page_size).await?;
(data.0.into_iter().map(Into::into).collect(), data.1)
}
})
}
pub async fn get_versions(&self, versions: Vec<String>) -> Result<Vec<ManagedVersion>> {
Ok(match self {
Self::Modrinth => modrinth::get_versions(versions),
}
.await?
.into_iter()
.map(Into::into)
.collect())
Self::Modrinth => modrinth::get_versions(versions)
.await?
.into_iter()
.map(Into::into)
.collect(),
Self::Curseforge => curseforge::get_versions(versions)
.await?
.into_iter()
.map(Into::into)
.collect(),
})
}
pub async fn get_version(&self, version_id: &str) -> Result<ManagedVersion> {
pub async fn get_version(&self, pkg_id: &str, version_id: &str) -> Result<ManagedVersion> {
Ok(match self {
Self::Modrinth => modrinth::get_version(version_id),
Self::Curseforge => todo!(),
}
.await?
.into())
}
pub async fn get_authors(&self, author: &Author) -> Result<Vec<ManagedUser>> {
match self {
Self::Modrinth => modrinth::get_authors(author),
}
.await
Ok(match author {
Author::Team { .. } => match self {
Self::Modrinth => modrinth::get_authors(author).await?,
_ => return Err(anyhow::anyhow!("{} does not support teams", self).into()),
},
Author::Users(users) => users.to_owned(),
})
}
pub async fn get_package_body(&self, body: &PackageBody) -> Result<String> {
Ok(match body {
PackageBody::Url(url) => match self {
Self::Curseforge => curseforge::get_package_body(url.to_owned()).await?,
_ => return Err(anyhow::anyhow!("{} does not support direct URLs", self).into()),
},
PackageBody::Markdown(markdown) => markdown.to_owned(),
})
}
}
fn build_facets(
builder: &mut FacetBuilder,
categories: Option<Vec<String>>,
game_versions: Option<Vec<String>>,
loaders: Option<Vec<Loader>>,
package_types: Option<Vec<PackageType>>,
open_source: Option<bool>,
) -> String {
if let Some(categories) = categories {
for category in categories {
builder.and(Facet(
"categories".to_string(),
FacetOperation::Eq,
category,
));
}
}
if let Some(game_versions) = game_versions {
for version in game_versions {
builder.and(Facet("versions".to_string(), FacetOperation::Eq, version));
}
}
if let Some(package_types) = package_types {
if package_types.contains(&PackageType::Mod)
|| package_types.contains(&PackageType::ModPack)
{
if let Some(loaders) = loaders {
for loader in loaders {
builder.and(Facet(
"categories".to_string(),
FacetOperation::Eq,
loader.to_string(),
));
}
}
}
for package_type in package_types {
builder.and(Facet(
"project_types".to_string(),
FacetOperation::Eq,
package_type.get_name().to_string(),
));
}
}
if let Some(open_source) = open_source {
builder.and(Facet(
"open_source".to_string(),
FacetOperation::Eq,
open_source.to_string(),
));
}
// builder.and(Facet("client_side".to_string(), FacetOperation::Eq, "")) // TODO: Possibly make this client_side = required
builder.build()
}

View file

@ -3,10 +3,10 @@ use std::fmt::{Display, Formatter};
use crate::data::{Loader, ManagedPackage, ManagedUser, ManagedVersion, PackageType};
use crate::store::{
Author, License, ManagedVersionFile, PackageFile, PackageSide, ProviderSearchResults,
SearchResult,
Author, License, ManagedVersionFile, ManagedVersionReleaseType, PackageFile, PackageSide, ProviderSearchResults, SearchResult
};
use crate::utils::http::fetch;
use crate::utils::pagination::Pagination;
use crate::{Result, State};
use chrono::{DateTime, Utc};
@ -68,7 +68,7 @@ impl From<ModrinthPackage> for ManagedPackage {
id: value.id,
title: value.title,
description: value.description,
body: value.body,
body: crate::store::PackageBody::Markdown(value.body),
main: value.slug,
versions: value.versions,
game_versions: value.game_versions,
@ -78,11 +78,10 @@ impl From<ModrinthPackage> for ManagedPackage {
updated: value.updated,
client: value.client_side,
server: value.server_side,
downloads: value.downloads,
downloads: value.downloads as u64,
followers: value.followers,
categories: value.categories,
optional_categories: Some(value.additional_categories),
uid: None,
package_type: PackageType::Mod,
license: value.license,
author: Author::Team {
@ -146,7 +145,7 @@ pub struct File {
pub url: String,
pub filename: String,
pub primary: bool,
pub size: u32,
pub size: u64,
pub file_type: Option<PackageFile>,
}
@ -172,15 +171,16 @@ impl From<ModrinthVersion> for ManagedVersion {
name: value.name,
featured: value.featured,
version_id: value.version_number,
version_display: value.version_number,
changelog: value.changelog,
changelog_url: value.changelog_url,
published: value.date_published,
downloads: value.downloads,
version_type: value.version_type,
version_type: ManagedVersionReleaseType::from(value.version_type),
files: value.files.into_iter().map(Into::into).collect(),
is_available: true,
deps: vec![], // TODO [`ManagedDependency`]?
game_versions: value.game_versions,
loaders: value
@ -307,17 +307,6 @@ pub async fn get_multiple(ids: &[String]) -> Result<Vec<ModrinthPackage>> {
)?)
}
pub async fn list() -> Result<Vec<ModrinthPackage>> {
Ok(serde_json::from_slice(
&fetch(
format_url!("/projects_random?count=10").as_str(),
None,
&State::get().await?.fetch_semaphore,
)
.await?,
)?)
}
pub async fn search<F>(
query: Option<String>,
limit: Option<u8>,
@ -451,7 +440,9 @@ pub async fn get_all_versions(
project_id: &str,
game_versions: Option<Vec<String>>,
loaders: Option<Vec<Loader>>,
) -> Result<Vec<ModrinthVersion>> {
page: Option<u32>,
page_size: Option<u16>,
) -> Result<(Vec<ModrinthVersion>, Pagination)> {
let mut url = url::Url::parse(format_url!("/project/{}/version", project_id).as_str())?;
if let Some(game_versions) = game_versions {
url.query_pairs_mut().append_pair(
@ -479,14 +470,26 @@ pub async fn get_all_versions(
);
}
Ok(serde_json::from_slice(
let data = serde_json::from_slice::<Vec<ModrinthVersion>>(
&fetch(
format_url!("/project/{}/version", project_id).as_str(),
None,
&State::get().await?.fetch_semaphore,
)
.await?,
)?)
)?;
let data_len = data.len() as u32;
Ok((
data,
Pagination {
index: page.unwrap_or(1),
page_size: page_size.unwrap_or(10),
result_count: data_len,
total_count: data_len,
},
))
}
pub async fn get_versions(versions: Vec<String>) -> Result<Vec<ModrinthVersion>> {

View file

@ -119,10 +119,7 @@ impl ProxyState {
Ok(value.app.get_webview_window("main").unwrap())
}
#[allow(
clippy::unused_async,
reason = "clippy doesn't take into account the features"
)]
#[allow(clippy::unused_async)]
#[cfg(feature = "tauri")]
pub async fn get() -> crate::Result<Arc<Self>> {
Ok(PROXY_STATE.get().ok_or(ProxyError::NotInitialized)?.clone())

View file

@ -31,12 +31,17 @@ pub const MINECRAFT_REDIRECT_URL: &str = "https://login.live.com/oauth20_desktop
pub const MINECRAFT_SCOPES: &str = "service::user.auth.xboxlive.com::MBI_SSL";
// =========== API ===========
// !!! URLS must NOT have a trailing slash. !!!
/// The Modrinth API base url.
pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2";
/// The Modrinth V3 API base url. Used for things like fetching information about organizations.
pub const MODRINTH_V3_API_URL: &str = "https://api.modrinth.com/v3";
/// The Curseforge API base url.
pub const CURSEFORGE_API_URL: &str = "https://api.curseforge.com/";
pub const CURSEFORGE_API_URL: &str = "https://api.curseforge.com";
/// The Curseforge API key. This is a secret and should not be shared.
pub const CURSEFORGE_API_KEY: Option<&'static str> = option_env!("CURSEFORGE_API_KEY");
/// The Minecraft game ID on Curseforge.
pub const CURSEFORGE_GAME_ID: u32 = 432;
/// Our metadata API base url.
pub const METADATA_API_URL: &str = "https://meta.polyfrost.org";
/// <https://mclo.gs>/ API base url.

View file

@ -13,8 +13,6 @@ use tokio::fs::DirEntry;
use super::{ClusterPath, Clusters, Directories, PackagePath};
// TODO: Curseforge, Modrinth, SkyClient integration
/// Represents types of packages handled by the launcher.
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -143,7 +141,7 @@ pub struct SearchResult {
pub client_side: PackageSide,
pub server_side: PackageSide,
pub project_type: PackageType,
pub downloads: u32,
pub downloads: u64,
#[serde(default)]
pub icon_url: String,
pub project_id: String,
@ -154,8 +152,6 @@ pub struct SearchResult {
pub follows: u32,
pub date_created: DateTime<Utc>,
pub date_modified: DateTime<Utc>,
#[serde(default)]
pub license: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@ -558,7 +554,7 @@ impl PackageMetadata {
Self::Managed {
package_id: package.id,
version_id: version.id,
version_formatted: version.version_id,
version_formatted: version.version_display,
title: package.title,
provider: package.provider,
package_type: package.package_type,
@ -607,6 +603,13 @@ pub enum Author {
Users(Vec<ManagedUser>),
}
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum PackageBody {
Url(String),
Markdown(String),
}
/// Universal metadata for any managed package from a Mod distribution platform.
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[derive(Serialize, Deserialize, Clone, Debug)]
@ -614,11 +617,10 @@ pub struct ManagedPackage {
// Core Metadata
pub provider: Providers,
pub id: String,
pub uid: Option<String>,
pub package_type: PackageType,
pub title: String,
pub description: String,
pub body: String,
pub body: PackageBody,
pub main: String,
pub versions: Vec<String>,
pub game_versions: Vec<String>,
@ -629,7 +631,7 @@ pub struct ManagedPackage {
pub updated: DateTime<Utc>,
pub client: PackageSide,
pub server: PackageSide,
pub downloads: u32,
pub downloads: u64,
pub followers: u32,
pub categories: Vec<String>,
pub optional_categories: Option<Vec<String>>,
@ -660,15 +662,16 @@ pub struct ManagedVersion {
pub name: String,
pub featured: bool,
pub version_id: String,
pub version_display: String,
pub changelog: String,
pub changelog_url: Option<String>,
pub published: DateTime<Utc>,
pub downloads: u32,
pub version_type: String,
pub version_type: ManagedVersionReleaseType,
pub files: Vec<ManagedVersionFile>,
pub is_available: bool,
pub deps: Vec<ManagedDependency>,
pub game_versions: Vec<String>,
pub loaders: Vec<Loader>,
@ -681,6 +684,39 @@ impl ManagedVersion {
}
}
/// Universal version release type
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
pub enum ManagedVersionReleaseType {
#[default]
Release,
Alpha,
Beta,
Snapshot,
}
impl From<String> for ManagedVersionReleaseType {
fn from(s: String) -> Self {
match s.to_lowercase().as_str() {
"alpha" => Self::Alpha,
"beta" => Self::Beta,
"snapshot" => Self::Snapshot,
_ => Self::Release,
}
}
}
impl ToString for ManagedVersionReleaseType {
fn to_string(&self) -> String {
match self {
Self::Release => "Release",
Self::Alpha => "Alpha",
Self::Beta => "Beta",
Self::Snapshot => "Snapshot",
}.to_string()
}
}
/// Universal interface for managed package files.
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
@ -689,7 +725,7 @@ pub struct ManagedVersionFile {
pub file_name: String,
pub primary: bool,
pub size: u32,
pub size: u64,
pub file_type: Option<PackageFile>,
pub hashes: HashMap<String, String>,
}

View file

@ -2,6 +2,7 @@
//!
//! Async extensions and wrappers around [`reqwest`] functions.
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
@ -79,7 +80,7 @@ pub async fn fetch_advanced(
url: &str,
sha1: Option<&str>,
json_body: Option<serde_json::Value>,
header: Option<(&str, &str)>,
headers: Option<HashMap<&str, &str>>,
ingress: Option<(&IngressId, f64)>,
semaphore: &FetchSemaphore,
) -> crate::Result<Bytes> {
@ -93,8 +94,10 @@ pub async fn fetch_advanced(
req = req.json(&body);
}
if let Some(header) = header {
req = req.header(header.0, header.1);
if let Some(headers) = &headers {
for (key, value) in headers {
req = req.header(*key, *value);
}
}
let result = req.send().await;

View file

@ -7,6 +7,7 @@
pub mod http;
pub mod java;
pub mod pagination;
pub mod watcher;
#[cfg(feature = "tauri")]

View file

@ -0,0 +1,23 @@
use serde::{Deserialize, Serialize};
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct Pagination {
pub index: u32,
pub page_size: u16,
pub result_count: u32,
pub total_count: u32,
}
pub fn get_page_offset(page: Option<u32>, page_size: Option<u16>) -> (u32, u16) {
let page = page.unwrap_or(0);
let page_size = page_size.unwrap_or(10);
let offset = (page - 1) * page_size as u32;
(offset, page_size)
}
pub fn get_page_count(total: u32, page_size: u16) -> u32 {
(total as f64 / page_size as f64).ceil() as u32
}

View file

@ -0,0 +1,120 @@
import process from 'node:process';
import { findWorkspaceDir } from '@pnpm/find-workspace-dir';
import dotenv from 'dotenv';
interface MappedCategoryItem {
display: string;
id: string;
}
type MappingList = Record<string, MappedCategoryItem[]>;
const packageTypes = ['mod', 'modpack', 'shader', 'resourcepack'] as const;
type PackageType = typeof packageTypes[number];
async function loadEnv() {
const path = await findWorkspaceDir(process.cwd());
console.log(`Loading .env from ${path}`);
dotenv.config({ path: `${path}/.env` });
}
async function fetchCategoriesCurseforge(packageType: PackageType): Promise<MappingList> {
const key = process.env.CURSEFORGE_API_KEY;
if (key === undefined) {
console.error('CURSEFORGE_API_KEY is not defined');
return {};
}
const response = await fetch('https://api.curseforge.com/v1/categories?gameId=432', {
headers: {
'x-api-key': key,
},
});
interface CFCategory {
id: number;
name: 'Server Utility';
slug: 'server-utility';
url: 'https://www.curseforge.com/minecraft/mc-mods/server-utility';
iconUrl: 'https://media.forgecdn.net/avatars/6/48/635351498950580836.png';
classId?: number;
isClass?: boolean;
parentCategoryId?: number;
};
const data = (await response.json() as { data: CFCategory[] }).data;
// TODO: Add more calssIds
const classIds: Record<PackageType, number> = {
mod: 6,
resourcepack: 12,
modpack: 4471,
shader: 6552,
};
const mapping: MappingList = {};
mapping.Curseforge = data
.filter((category) => {
return category.isClass !== true && category.classId === classIds[packageType];
})
.map((category) => {
return {
display: category.name,
id: category.id.toString(),
};
});
return mapping;
}
async function fetchCategoriesModrinth(packageType: PackageType): Promise<MappingList> {
const response = await fetch('https://api.modrinth.com/v2/tag/category');
interface ModrinthCategory {
icon: string;
name: string;
project_type: PackageType;
header: string;
}
const data = await response.json() as ModrinthCategory[];
const mapping: MappingList = {};
mapping.Modrinth = data
.filter((category) => {
return category.project_type === packageType;
})
.map((category) => {
return {
display: category.name.charAt(0).toUpperCase() + category.name.slice(1),
id: category.name,
};
});
return mapping;
}
(async () => {
const arg = process.argv[2];
if (arg === undefined) {
console.error(`Please provide an argument for package type.\n Valid: '${packageTypes.join('\', \'')}'`);
return;
}
const packageType = arg as PackageType;
await loadEnv();
const cfCategories = await fetchCategoriesCurseforge(packageType);
const modrinthCategories = await fetchCategoriesModrinth(packageType);
const categories = {
...cfCategories,
...modrinthCategories,
};
console.log(categories);
})();

View file

@ -22,10 +22,13 @@
"setup-nix": "./setup.sh",
"autoformat": "./format.sh",
"fix-deb": "./fix-deb.sh",
"dupe": "./dedupe.sh"
"dupe": "./dedupe.sh",
"api:categories": "pnpm tsx ./api/fetch_categories.ts"
},
"dependencies": {
"@pnpm/find-workspace-dir": "^7.0.1",
"consola": "^3.2.3",
"dotenv": "^16.4.5",
"execa": "^9.4.0",
"mustache": "^4.2.0",
"pathe": "^1.1.2",

29
pnpm-lock.yaml generated
View file

@ -269,9 +269,15 @@ importers:
packages/scripts:
dependencies:
'@pnpm/find-workspace-dir':
specifier: ^7.0.1
version: 7.0.1
consola:
specifier: ^3.2.3
version: 3.2.3
dotenv:
specifier: ^16.4.5
version: 16.4.5
execa:
specifier: ^9.4.0
version: 9.4.0
@ -1027,6 +1033,18 @@ packages:
resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@pnpm/constants@8.0.0':
resolution: {integrity: sha512-yQosGUvYPpAjb1jOFcdbwekRjZRVxN6C0hHzfRCZrMKbxGjt/E0g0RcFlEDNVZ95tm4oMMcr7nEPa7H7LX3emw==}
engines: {node: '>=18.12'}
'@pnpm/error@6.0.1':
resolution: {integrity: sha512-7yjO0RgmWYb4OKgcWC33yD4Z2CxE7Tm7vXX1SmS7GDifDT/bgZZhHeS2xq/+W6y9yhwIrRSA+7AlQL1NM2wIvw==}
engines: {node: '>=18.12'}
'@pnpm/find-workspace-dir@7.0.1':
resolution: {integrity: sha512-o1LAFM/5MChI6qBolMBOznzatch01UK3wIgoAE/b779qs1FakksB278nMRTwRY58PZSBT+RxZ2RCMjlxPLeVWw==}
engines: {node: '>=18.12'}
'@polka/url@1.0.0-next.25':
resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==}
@ -4910,6 +4928,17 @@ snapshots:
'@pkgr/core@0.1.1': {}
'@pnpm/constants@8.0.0': {}
'@pnpm/error@6.0.1':
dependencies:
'@pnpm/constants': 8.0.0
'@pnpm/find-workspace-dir@7.0.1':
dependencies:
'@pnpm/error': 6.0.1
find-up: 5.0.0
'@polka/url@1.0.0-next.25': {}
'@prisma/debug@5.20.0': {}