feat: SkyClient Part 1

This commit is contained in:
LynithDev 2024-12-07 15:00:38 +01:00
parent 2554d233f4
commit f49dc71e9d
15 changed files with 410 additions and 85 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

View file

@ -1,6 +1,7 @@
import type { ManagedPackage, Providers } from '@onelauncher/client/bindings';
import CurseforgeIcon from '~assets/logos/curseforge.svg?component-solid';
import ModrinthImage from '~assets/logos/modrinth.svg?component-solid';
import SkyClientImage from '~assets/logos/skyclient.png';
import { type Component, type JSX, Match, Show, splitProps, Switch } from 'solid-js';
export function getProviderLogoElement(provider: ManagedPackage | Providers): string | Component {
@ -10,6 +11,7 @@ export function getProviderLogoElement(provider: ManagedPackage | Providers): st
const mapping: Record<Lowercase<Providers>, string | Component> = {
modrinth: ModrinthImage,
curseforge: CurseforgeIcon,
skyclient: SkyClientImage,
};
return mapping[providerName];

View file

@ -160,16 +160,18 @@ function PackageItem(props: SearchResult & { provider: Providers; row?: boolean
<p class="max-h-22 flex-1 overflow-hidden text-sm text-fg-secondary line-height-snug">{props.description}</p>
<div class="flex flex-row gap-4 text-xs">
<div class="flex flex-row items-center gap-2">
<Download01Icon class="h-4 w-4" />
{abbreviateNumber(props.downloads)}
</div>
<Show when={props.follows > 0}>
<Show when={props.provider !== 'SkyClient'}>
<div class="flex flex-row items-center gap-2">
<HeartIcon class="h-4 w-4" />
{abbreviateNumber(props.follows)}
<Download01Icon class="h-4 w-4" />
{abbreviateNumber(props.downloads)}
</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>
</Show>
</div>
</div>

View file

@ -137,8 +137,8 @@ export default BrowserPackage;
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 createdAt = createMemo(() => props.package.created ? new Date(props.package.created) : null);
const updatedAt = createMemo(() => props.package.updated ? new Date(props.package.updated) : null);
const promptOpen = usePromptOpener();
return (
@ -166,16 +166,18 @@ function BrowserSidebar(props: { package: ManagedPackage }) {
<p class="max-h-22 flex-1 overflow-hidden text-sm text-fg-secondary line-height-snug">{props.package.description}</p>
<div class="flex flex-row gap-4 text-xs">
<div class="flex flex-row items-center gap-2">
<Download01Icon class="h-4 w-4" />
{abbreviateNumber(props.package.downloads)}
</div>
<Show when={props.package.followers > 0}>
<Show when={props.package.provider !== 'SkyClient'}>
<div class="flex flex-row items-center gap-2">
<HeartIcon class="h-4 w-4" />
{abbreviateNumber(props.package.followers)}
<Download01Icon class="h-4 w-4" />
{abbreviateNumber(props.package.downloads)}
</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>
</Show>
</div>
</div>
@ -235,25 +237,29 @@ function BrowserSidebar(props: { package: ManagedPackage }) {
</div>
</Show>
<Tooltip text={createdAt().toLocaleString()}>
<div class="flex flex-row items-center gap-x-1">
<CalendarIcon class="h-3 min-w-3 w-3" />
Created
<span class="text-fg-primary font-medium">
{formatAsRelative(createdAt().getTime(), 'en', 'long')}
</span>
</div>
</Tooltip>
<Show when={createdAt() !== null}>
<Tooltip text={createdAt()!.toLocaleString()}>
<div class="flex flex-row items-center gap-x-1">
<CalendarIcon class="h-3 min-w-3 w-3" />
Created
<span class="text-fg-primary font-medium">
{formatAsRelative(createdAt()!.getTime(), 'en', 'long')}
</span>
</div>
</Tooltip>
</Show>
<Tooltip text={updatedAt().toLocaleString()}>
<div class="flex flex-row items-center gap-x-1">
<ClockRewindIcon class="h-3 min-w-3 w-3" />
Last Updated
<span class="text-fg-primary font-medium">
{formatAsRelative(updatedAt().getTime(), 'en', 'long')}
</span>
</div>
</Tooltip>
<Show when={updatedAt() !== null}>
<Tooltip text={updatedAt()!.toLocaleString()}>
<div class="flex flex-row items-center gap-x-1">
<ClockRewindIcon class="h-3 min-w-3 w-3" />
Last Updated
<span class="text-fg-primary font-medium">
{formatAsRelative(updatedAt()!.getTime(), 'en', 'long')}
</span>
</div>
</Tooltip>
</Show>
</div>
</div>

View file

@ -7,7 +7,7 @@ import ProviderIcon from '~ui/components/content/ProviderIcon';
import useBrowser from '~ui/hooks/useBrowser';
import { PROVIDERS } from '~utils';
import { browserCategories } from '~utils/browser';
import { For, type JSX, type ParentProps } from 'solid-js';
import { createMemo, For, type JSX, type ParentProps, Show } from 'solid-js';
import BrowserMain from './BrowserMain';
import BrowserPackage from './BrowserPackage';
import BrowserSearch from './BrowserSearch';
@ -87,23 +87,29 @@ function BrowserCategories() {
browser.search();
};
const categories = createMemo(() => {
return browserCategories.byPackageType(browser.packageType(), browser.searchQuery().provider);
});
return (
<div class="top-0 grid grid-cols-[1fr_auto] h-fit min-w-50 gap-y-6">
<div />
<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(), browser.searchQuery().provider)}>
{category => (
<p
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}
</p>
)}
</For>
</div>
<Show when={categories().length > 0}>
<div class="flex flex-col gap-y-2">
<h6 class="my-1">Categories</h6>
<For each={categories()}>
{category => (
<p
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}
</p>
)}
</For>
</div>
</Show>
</div>
</div>
);

View file

@ -93,4 +93,5 @@ const modMapping: Record<Providers, CategoryItem[]> = {
{ display: 'Utility', id: 'utility' },
{ display: 'World Generation', id: 'worldgen' },
],
SkyClient: [],
};

View file

@ -174,13 +174,14 @@ export function getPackageUrl(pkg: ManagedPackage): string {
return `https://www.curseforge.com/minecraft/${packageTypeMapping[pkg.package_type]}/${pkg.main}`;
},
SkyClient: () => 'TODO',
};
return mapping[pkg.provider]();
}
export const LOADERS: Loader[] = ['vanilla', 'fabric', 'forge', 'neoforge', 'quilt'] as const;
export const PROVIDERS: Providers[] = ['Modrinth', 'Curseforge'] as const;
export const PROVIDERS: Providers[] = ['Modrinth', 'Curseforge', 'SkyClient'] as const;
export const PACKAGE_TYPES: PackageType[] = ['mod', 'resourcepack', 'datapack', 'shaderpack'] as const;
export const LAUNCHER_IMPORT_TYPES: ImportType[] = [
'PrismLauncher',

View file

@ -857,7 +857,7 @@ export interface ManagedDependency { version_id: string | null; package_id: stri
/**
* Universal metadata for any managed package from a Mod distribution platform.
*/
export interface ManagedPackage { provider: Providers; id: string; package_type: PackageType; title: string; description: string; body: PackageBody; main: string; versions: string[]; game_versions: string[]; loaders: Loader[]; icon_url: string | null; created: string; updated: string; client: PackageSide; server: PackageSide; downloads: bigint; followers: number; categories: string[]; optional_categories: string[] | null; license: License | null; author: Author; is_archived: boolean };
export interface ManagedPackage { provider: Providers; id: string; package_type: PackageType; title: string; description: string; body: PackageBody; main: string; versions: string[]; game_versions: string[]; loaders: Loader[]; icon_url: string | null; created: string | null; updated: string | null; client: PackageSide; server: PackageSide; downloads: bigint; followers: number; categories: string[]; optional_categories: string[] | null; license: License | null; author: Author; is_archived: boolean };
/**
* Universal metadata for any managed user from a Mod distribution platform.
*/
@ -989,12 +989,12 @@ export interface ProviderSearchResults { provider: Providers; results: SearchRes
/**
* Providers for content packages
*/
export type Providers = 'Modrinth' | 'Curseforge';
export type Providers = 'Modrinth' | 'Curseforge' | 'SkyClient';
/**
* Global Minecraft resolution.
*/
export type Resolution = [number, number];
export interface SearchResult { slug: string; title: string; description: string; categories?: string[]; client_side: PackageSide; server_side: PackageSide; project_type: PackageType; downloads: bigint; icon_url?: string; project_id: string; author: string; display_categories?: string[]; versions: string[]; follows: number; date_created: string; date_modified: string };
export interface SearchResult { slug: string; title: string; description: string; categories?: string[]; client_side: PackageSide; server_side: PackageSide; project_type: PackageType; downloads: bigint; icon_url?: string; project_id: string; author: string; versions: string[]; follows: number; date_created: string | null; date_modified: string | null };
/**
* A global settings state for the launcher.
*/

View file

@ -146,8 +146,8 @@ impl From<CurseforgePackage> for ManagedPackage {
game_versions: vec![],
loaders: vec![],
icon_url: package.logo.and_then(|l| Some(l.url)),
created: package.date_created,
updated: package.date_modified,
created: Some(package.date_created),
updated: Some(package.date_modified),
client: crate::store::PackageSide::Unknown,
server: crate::store::PackageSide::Unknown,
downloads: package.download_count,
@ -193,11 +193,10 @@ impl Into<SearchResult> for CurseforgePackage {
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,
date_created: Some(self.date_created),
date_modified: Some(self.date_modified),
}
}
}

View file

@ -17,6 +17,7 @@ use crate::{Result, State};
mod curseforge;
mod modrinth;
mod skyclient;
/// Providers for content packages
#[cfg_attr(feature = "specta", derive(specta::Type))]
@ -24,6 +25,7 @@ mod modrinth;
pub enum Providers {
Modrinth,
Curseforge,
SkyClient,
}
impl std::fmt::Display for Providers {
@ -39,6 +41,7 @@ impl Providers {
match self {
Self::Modrinth => "Modrinth",
Self::Curseforge => "Curseforge",
Self::SkyClient => "SkyClient",
}
}
@ -48,11 +51,12 @@ impl Providers {
match self {
Self::Modrinth => "https://modrinth.com",
Self::Curseforge => "https://curseforge.com",
Self::SkyClient => "https://skyclient.co",
}
}
pub const fn get_providers() -> &'static [Providers] {
&[Self::Modrinth, Self::Curseforge]
&[Self::Modrinth, Self::Curseforge, Self::SkyClient]
}
#[allow(clippy::too_many_arguments)]
@ -98,35 +102,37 @@ impl Providers {
)
.await
}
Self::SkyClient => {
skyclient::search(
query,
limit,
offset,
game_versions,
loaders
).await
},
}
}
pub async fn get(&self, slug_or_id: &str) -> Result<ManagedPackage> {
Ok(match self {
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(),
Self::Curseforge => curseforge::get(slug_or_id.parse::<u32>().map_err(|err| anyhow::anyhow!(err))?).await?.into(),
Self::SkyClient => skyclient::get(slug_or_id).await?.into(),
})
}
pub async fn get_multiple(&self, slug_or_ids: &[String]) -> Result<Vec<ManagedPackage>> {
Ok(match self {
Self::Modrinth => {
if slug_or_ids.len() <= 0 {
return Ok(vec![]);
}
if slug_or_ids.len() <= 0 {
return Ok(vec![]);
}
modrinth::get_multiple(slug_or_ids)
Ok(match self {
Self::Modrinth => modrinth::get_multiple(slug_or_ids)
.await?
.into_iter()
.map(Into::into)
.collect()
},
.collect(),
Self::Curseforge => {
let parsed_ids = slug_or_ids
.iter()
@ -145,6 +151,11 @@ impl Providers {
.map(Into::into)
.collect()
},
Self::SkyClient => skyclient::get_multiple(slug_or_ids)
.await?
.into_iter()
.map(Into::into)
.collect(),
})
}
@ -166,6 +177,8 @@ impl Providers {
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)
}
Self::SkyClient => todo!(),
})
}
@ -181,6 +194,7 @@ impl Providers {
.into_iter()
.map(Into::into)
.collect(),
Self::SkyClient => todo!(),
})
}
@ -215,7 +229,8 @@ impl Providers {
.await?
.into_iter()
.map(|(hash, version)| (hash, version.into()))
.collect()
.collect(),
Self::SkyClient => todo!(),
})
}
}

View file

@ -75,8 +75,8 @@ impl From<ModrinthPackage> for ManagedPackage {
game_versions: value.game_versions,
loaders: value.loaders,
icon_url: value.icon_url,
created: value.published,
updated: value.updated,
created: Some(value.published),
updated: Some(value.updated),
client: value.client_side,
server: value.server_side,
downloads: value.downloads as u64,

View file

@ -0,0 +1,288 @@
use std::sync::Arc;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tokio::sync::{OnceCell, RwLock};
use crate::{data::{Loader, ManagedPackage, ManagedUser, ManagedVersion}, store::{ProviderSearchResults, SearchResult}, utils::{http, pagination::Pagination}, Result, State};
async fn fetch<T: DeserializeOwned>(url: &str) -> Result<T> {
let state = State::get().await?;
Ok(serde_json::from_slice(
&http::fetch(
format!("{}/{}", crate::constants::SKYCLIENT_BASE_URL, url).as_str(),
None,
&state.fetch_semaphore
).await?
)?)
}
static SKYCLIENTSTORE_STATIC: OnceCell<RwLock<SkyClientStore>> = OnceCell::const_new();
struct SkyClientStore {
pub mods: Option<Vec<SkyClientMod>>
}
impl SkyClientStore {
pub async fn get() -> Result<Arc<tokio::sync::RwLockReadGuard<'static, Self>>> {
Ok(Arc::new(
SKYCLIENTSTORE_STATIC
.get_or_try_init(Self::initialize)
.await?
.read()
.await,
))
}
#[tracing::instrument]
#[onelauncher_macros::memory]
async fn initialize() -> Result<RwLock<Self>> {
let mods: Vec<SkyClientMod> = fetch("/mods/mods.json").await?;
Ok(RwLock::new(SkyClientStore {
mods: Some(mods)
}))
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct SkyClientMod {
pub id: String,
#[serde(default)]
pub nicknames: Vec<String>,
pub display: String,
pub creator: String,
pub discord_code: Option<String>,
pub description: Option<String>,
pub icon: Option<String>,
#[serde(default)]
pub links: Vec<SkyClientModLink>,
pub versions: Vec<SkyClientModVersion>,
#[serde(default)]
pub commands: Vec<String>,
pub icon_scaling: Option<String>,
pub oneconfig: Option<bool>,
}
impl Into<SearchResult> for SkyClientMod {
fn into(self) -> SearchResult {
SearchResult {
slug: self.id.clone(),
project_id: self.id,
author: self.creator,
categories: vec![],
title: self.display,
client_side: crate::store::PackageSide::Required,
server_side: crate::store::PackageSide::Unknown,
date_created: None,
date_modified: None,
description: self.description.unwrap_or_default(),
project_type: crate::data::PackageType::Mod,
downloads: 0,
icon_url: self.icon.map(|i| format!("{}/icons/{}", crate::constants::SKYCLIENT_BASE_URL, i)).unwrap_or_default(),
versions: self.versions.iter().map(|v| v.version.clone()).collect(),
follows: 0,
}
}
}
impl Into<ManagedPackage> for SkyClientMod {
fn into(self) -> ManagedPackage {
ManagedPackage {
id: self.id.clone(),
main: self.id,
title: self.display,
body: crate::store::PackageBody::Markdown(self.description.clone().unwrap_or_default()),
categories: vec![],
client: crate::store::PackageSide::Required,
server: crate::store::PackageSide::Unknown,
created: None,
updated: None,
description: self.description.unwrap_or_default(),
downloads: 0,
followers: 0,
game_versions: self.versions.iter().flat_map(|v| v.game_versions.clone()).collect(),
icon_url: self.icon.map(|i| format!("{}/icons/{}", crate::constants::SKYCLIENT_BASE_URL, i)),
is_archived: false,
license: None,
loaders: self.versions.iter().flat_map(|v| v.loaders.clone().into_iter().map(Loader::from_string)).collect(),
versions: self.versions.iter().map(|v| v.version.clone()).collect(),
optional_categories: None,
author: crate::store::Author::Users(vec![
ManagedUser {
id: self.creator.clone(),
username: self.creator,
is_organization_user: false,
avatar_url: None,
bio: None,
role: None,
url: self.discord_code.map(|code| format!("https://discord.gg/{}", code)),
}
]),
package_type: crate::data::PackageType::Mod,
provider: super::Providers::SkyClient,
}
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct SkyClientModLink {
pub icon: String,
pub text: String,
pub link: String,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct SkyClientModVersion {
pub version: String,
pub game_versions: Vec<String>,
pub loaders: Vec<String>,
pub file: String,
pub url: String,
pub hash: String,
pub mod_id: Option<String>,
}
pub async fn get(id: &str) -> Result<SkyClientMod> {
let store = SkyClientStore::get().await?;
let mods = match &store.mods {
Some(mods) => mods,
None => return Err(anyhow::anyhow!("mod not found").into())
};
for m in mods {
if m.id == id {
return Ok(m.clone());
}
}
Err(anyhow::anyhow!("mod not found").into())
}
pub async fn get_multiple(slug_or_ids: &[String]) -> Result<Vec<SkyClientMod>> {
let store = SkyClientStore::get().await?;
let mods = match &store.mods {
Some(mods) => mods,
None => return Ok(vec![])
};
let mut results = vec![];
for id in slug_or_ids {
for m in mods {
if m.id == *id {
results.push(m.clone());
}
}
}
Ok(results)
}
// 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<ManagedVersion>, Pagination)> {
// let store = SkyClientStore::get().await?;
// let mods = match &store.mods {
// Some(mods) => mods,
// None => return Ok((vec![], Pagination::default()))
// };
// let mut versions = vec![];
// for m in mods {
// if m.id == project_id {
// for v in m.versions {
// let mut can_add = true;
// if let Some(game_versions) = &game_versions {
// can_add = v.game_versions.iter().any(|gv| game_versions.contains(gv))
// }
// if can_add {
// if let Some(loaders) = &loaders {
// can_add = v.loaders.iter().any(|l| loaders.contains(&Loader::from_string(l)))
// }
// }
// if can_add {
//
// }
// }
// }
// }
// }
pub async fn search(
query: Option<String>,
limit: Option<u8>,
offset: Option<u32>,
game_versions: Option<Vec<String>>,
// package_types: Option<Vec<PackageType>>,
loaders: Option<Vec<Loader>>,
) -> Result<ProviderSearchResults> {
let store = SkyClientStore::get().await?;
let mut results = ProviderSearchResults {
provider: super::Providers::SkyClient,
results: vec![],
total: 0,
};
let mods = match &store.mods {
Some(mods) => mods,
None => return Ok(results),
};
let query = query.map(|q| q.trim().to_lowercase());
let limit = limit.map(|l| l as usize);
let offset = offset.unwrap_or(0);
for m in mods {
let mut can_add = true;
if let Some(query) = &query {
if !query.is_empty() {
let display = m.display.to_lowercase();
can_add = display.contains(query) || m.id.contains(query) || m.nicknames.contains(query)
}
}
if can_add {
if let Some(game_versions) = &game_versions {
if !game_versions.is_empty() {
can_add = m.versions.iter().any(|v| v.game_versions.iter().any(|gv| game_versions.contains(&gv)))
}
}
}
if can_add {
if let Some(loaders) = &loaders {
if !loaders.is_empty() {
can_add = m.versions.iter().any(|v| v.loaders.iter().any(|l| loaders.contains(&Loader::from_string(l.to_owned()))))
}
}
}
if can_add {
if limit.map(|l| results.results.len() < l).unwrap_or(true) && offset <= results.total {
let result: SearchResult = m.clone().into();
results.results.push(result);
}
results.total += 1;
}
}
Ok(results)
}

View file

@ -48,6 +48,8 @@ pub const METADATA_API_URL: &str = "https://meta.polyfrost.org";
pub const FEATURED_PACKAGES_URL: &str = "https://polyfrost.org/meta/onelauncher/featured.json";
/// <https://mclo.gs>/ API base url.
pub const MCLOGS_API_URL: &str = "https://api.mclo.gs/1";
/// https://skyclient.co/ metadata base url.
pub const SKYCLIENT_BASE_URL: &str = "https://raw.githubusercontent.com/SkyblockClient/SkyblockClient-REPO/refs/heads/main/v1";
// =========== Paths ===========
/// The public `settings.json` file used to store the global [`Settings`] state.

View file

@ -376,7 +376,12 @@ impl Loader {
}
#[must_use]
pub fn from_string(val: &str) -> Self {
pub fn from_string(val: String) -> Self {
Self::from_str(val.as_str())
}
#[must_use]
pub fn from_str(val: &str) -> Self {
match val {
"forge" => Self::Forge,
"fabric" => Self::Fabric,

View file

@ -148,12 +148,10 @@ pub struct SearchResult {
pub icon_url: String,
pub project_id: String,
pub author: String,
#[serde(default)]
pub display_categories: Vec<String>,
pub versions: Vec<String>,
pub follows: u32,
pub date_created: DateTime<Utc>,
pub date_modified: DateTime<Utc>,
pub date_created: Option<DateTime<Utc>>,
pub date_modified: Option<DateTime<Utc>>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@ -719,8 +717,8 @@ pub struct ManagedPackage {
pub loaders: Vec<Loader>,
pub icon_url: Option<String>,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
pub created: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
pub client: PackageSide,
pub server: PackageSide,
pub downloads: u64,