From a5c142d2702b295926742a3124809336169f8b12 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Thu, 19 Sep 2024 21:35:33 +0200 Subject: [PATCH 1/8] feat: Start work on Curseforge API integration --- Cargo.lock | 2 + Cargo.toml | 2 + apps/testing/examples/search_packages.rs | 16 +- packages/core/.template.env | 2 + packages/core/Cargo.toml | 4 + packages/core/build.rs | 9 + .../src/api/package/content/curseforge.rs | 410 ++++++++++++++++++ packages/core/src/api/package/content/mod.rs | 160 ++++--- .../core/src/api/package/content/modrinth.rs | 12 - packages/core/src/constants.rs | 7 +- packages/core/src/store/package.rs | 5 +- packages/core/src/utils/http.rs | 9 +- 12 files changed, 545 insertions(+), 93 deletions(-) create mode 100644 packages/core/.template.env create mode 100644 packages/core/build.rs create mode 100644 packages/core/src/api/package/content/curseforge.rs diff --git a/Cargo.lock b/Cargo.lock index 4de63f59..56fd1d0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4357,6 +4357,7 @@ dependencies = [ "chrono", "dirs 5.0.1", "discord-rich-presence", + "dotenvy", "flate2", "futures", "indicatif", @@ -4377,6 +4378,7 @@ dependencies = [ "serde", "serde_ini", "serde_json", + "serde_repr", "sha1_smol", "sha2 0.10.8", "specta 2.0.0-rc.20", diff --git a/Cargo.toml b/Cargo.toml index ec5ee592..5f78aeda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" ] } @@ -178,6 +179,7 @@ cocoa = { version = "0.26" } objc = { version = "0.2" } cc = { version = "1.1" } libc = { version = "0.2" } +dotenvy = { version = "0.15.7" } # https://github.com/tauri-apps/tauri/blob/dev/crates/tauri/Cargo.toml#L102 webkit2gtk = { version = "=2.0.1", features = [ "v2_40" ] } diff --git a/apps/testing/examples/search_packages.rs b/apps/testing/examples/search_packages.rs index fdb26cb3..6ce50830 100644 --- a/apps/testing/examples/search_packages.rs +++ b/apps/testing/examples/search_packages.rs @@ -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, @@ -23,4 +31,4 @@ async fn main() -> Result<()> { println!("{:#?}", results); Ok(()) -} +} \ No newline at end of file diff --git a/packages/core/.template.env b/packages/core/.template.env new file mode 100644 index 00000000..1fe71cf6 --- /dev/null +++ b/packages/core/.template.env @@ -0,0 +1,2 @@ +# You may need to escape any special characters such as $ +CURSEFORGE_API_KEY = "" diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index 276d8bac..a1983f55 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -36,6 +36,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 } @@ -76,6 +77,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 } diff --git a/packages/core/build.rs b/packages/core/build.rs new file mode 100644 index 00000000..4d667801 --- /dev/null +++ b/packages/core/build.rs @@ -0,0 +1,9 @@ +fn main() { + let dotenv_path = dotenvy::dotenv().expect("failed to find .env file"); + println!("cargo:rerun-if-changed={}", dotenv_path.display()); + + for env_var in dotenvy::dotenv_iter().unwrap() { + let (key, value) = env_var.unwrap(); + println!("cargo:rustc-env={key}={value}"); + } +} \ No newline at end of file diff --git a/packages/core/src/api/package/content/curseforge.rs b/packages/core/src/api/package/content/curseforge.rs new file mode 100644 index 00000000..d3f1c2ad --- /dev/null +++ b/packages/core/src/api/package/content/curseforge.rs @@ -0,0 +1,410 @@ +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, PackageType}, store::{ProviderSearchResults, SearchResult}, utils::http, 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 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 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 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, + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Pagination { + pub index: u32, + pub page_size: u32, + pub result_count: u32, + pub total_count: u32, +} + +#[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, + pub is_featured: bool, + pub primary_category_id: u32, + pub categories: Vec, + pub authors: Vec, + pub logo: Logo, + pub screenshots: Vec, + pub main_file_id: u32, + pub latest_files: Vec, + pub latest_files_indexes: Vec, + pub latest_early_access_files_indexes: Vec, + pub date_created: DateTime, + pub date_modified: DateTime, + pub date_released: DateTime, + #[serde(default)] + pub allow_mod_distribution: bool, + pub game_popularity_rank: u32, + pub is_available: bool, + pub thumbs_up_count: u32, + pub rating: Option, +} + +impl From for ManagedPackage { + fn from(package: CurseforgePackage) -> Self { + ManagedPackage { + id: package.id.to_string(), + title: package.name, + provider: todo!(), + package_type: todo!(), + description: todo!(), + body: todo!(), + main: todo!(), + versions: todo!(), + game_versions: todo!(), + loaders: todo!(), + icon_url: todo!(), + created: todo!(), + updated: todo!(), + client: todo!(), + server: todo!(), + downloads: todo!(), + followers: todo!(), + categories: todo!(), + optional_categories: todo!(), + license: todo!(), + author: todo!(), + is_archived: todo!(), + } + } +} + +impl Into 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.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, + pub wiki_url: Option, + pub issues_url: Option, + pub source_url: Option, +} + +#[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, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LatestFile { + pub id: u32, + pub game_id: u32, + pub mod_id: u32, + pub is_available: bool, + pub display_name: String, + pub file_name: String, + pub release_type: u32, + pub file_status: u32, + pub hashes: Vec, + pub file_date: String, + pub file_length: u32, + pub download_count: u32, + // pub file_size_on_disk: u32, + pub download_url: Option, + pub game_versions: Vec, + pub sortable_game_versions: Vec, + pub dependencies: Vec, + // 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, +} + +#[derive(Default, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Hash { + pub value: String, + pub algo: u32, +} + +#[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 LatestFilesIndex { + 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, +} + +#[derive(Default, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LatestEarlyAccessFilesIndex { + 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 +enum SearchSort { + Featured = 1, + Popularity = 2, + LastUpdated = 3, + Name = 4, + Author = 5, + TotalDownloads = 6, + Rating = 12, +} + +#[derive(Default, Debug, Serialize, Deserialize)] +struct CFData { + data: T, +} + +pub async fn fetch(url: &str, url_builder: Option>) -> Result { + // 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 = 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::GET, + url.as_str(), + None, + None, + Some(headers), + None, + &State::get().await?.fetch_semaphore, + ) + .await +} + +pub async fn search( + query: Option, + limit: Option, + offset: Option, + game_versions: Option>, + _categories: Option>, + loaders: Option>, + package_types: Option>, +) -> Result { + #[derive(Deserialize)] + struct CFSearchResults { + pagination: Pagination, + data: Vec + } + + let results: CFSearchResults = serde_json::from_slice( + &fetch( + "/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()); + 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", game_versions.into_iter().map(|v| format!("\"{}\"", v)).collect::>().join(",").as_str()); + } + + if let Some(loaders) = loaders { + params.append_pair("modLoaderTypes", loaders.into_iter().map(|l| (CurseforgeLoader::from(l) as u32).to_string()).collect::>().join(",").as_str()); + } + + // TODO: Merge this with categories + if let Some(package_types) = package_types { + params.append_pair("categoryIds", package_types.into_iter().map(|p| (CFPackageType::from(p) as u32).to_string()).collect::>().join(",").as_str()); + } + })) + ).await?)?; + + Ok(ProviderSearchResults { + total: results.pagination.total_count, + results: results.data.into_iter().map(Into::into).collect::>(), + provider: crate::package::content::Providers::Curseforge, + }) +} + +pub async fn get(id: u32) -> Result { + Ok(serde_json::from_slice::>( + &fetch( + format!("/v1/mods/{}", id).as_str(), + None, + ) + .await?, + )?.data) +} \ No newline at end of file diff --git a/packages/core/src/api/package/content/mod.rs b/packages/core/src/api/package/content/mod.rs index 0acf2931..fa748f3a 100644 --- a/packages/core/src/api/package/content/mod.rs +++ b/packages/core/src/api/package/content/mod.rs @@ -11,12 +11,14 @@ use crate::store::{Author, ProviderSearchResults}; use crate::Result; mod modrinth; +mod curseforge; /// Providers for content packages #[cfg_attr(feature = "specta", derive(specta::Type))] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] pub enum Providers { Modrinth, + Curseforge, } impl std::fmt::Display for Providers { @@ -31,6 +33,7 @@ impl Providers { pub const fn name(&self) -> &str { match self { Self::Modrinth => "Modrinth", + Self::Curseforge => "Curseforge", } } @@ -39,6 +42,7 @@ impl Providers { pub const fn url(&self) -> &str { match self { Self::Modrinth => "https://modrinth.com", + Self::Curseforge => "https://curseforge.com", } } @@ -59,85 +63,38 @@ impl Providers { 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() - }), - ), + 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> { - Ok(match self { - Self::Modrinth => modrinth::list(), - } - .await? - .into_iter() - .map(Into::into) - .collect()) } pub async fn get(&self, slug_or_id: &str) -> Result { 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::().map_err(|err| anyhow::anyhow!(err))?).await?.into(), + }) } pub async fn get_multiple(&self, slug_or_ids: &[String]) -> Result> { Ok(match self { Self::Modrinth => modrinth::get_multiple(slug_or_ids), + Self::Curseforge => todo!(), } .await? .into_iter() @@ -153,6 +110,7 @@ impl Providers { ) -> Result> { Ok(match self { Self::Modrinth => modrinth::get_all_versions(project_id, game_versions, loaders), + Self::Curseforge => todo!(), } .await? .into_iter() @@ -163,6 +121,7 @@ impl Providers { pub async fn get_versions(&self, versions: Vec) -> Result> { Ok(match self { Self::Modrinth => modrinth::get_versions(versions), + Self::Curseforge => todo!(), } .await? .into_iter() @@ -173,6 +132,7 @@ impl Providers { pub async fn get_version(&self, version_id: &str) -> Result { Ok(match self { Self::Modrinth => modrinth::get_version(version_id), + Self::Curseforge => todo!(), } .await? .into()) @@ -181,7 +141,69 @@ impl Providers { pub async fn get_authors(&self, author: &Author) -> Result> { match self { Self::Modrinth => modrinth::get_authors(author), + Self::Curseforge => todo!(), } .await } } + +fn build_facets( + builder: &mut FacetBuilder, + categories: Option>, + game_versions: Option>, + loaders: Option>, + package_types: Option>, + open_source: Option, +) -> 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() +} diff --git a/packages/core/src/api/package/content/modrinth.rs b/packages/core/src/api/package/content/modrinth.rs index c421b80c..296dc59b 100644 --- a/packages/core/src/api/package/content/modrinth.rs +++ b/packages/core/src/api/package/content/modrinth.rs @@ -82,7 +82,6 @@ impl From for ManagedPackage { followers: value.followers, categories: value.categories, optional_categories: Some(value.additional_categories), - uid: None, package_type: PackageType::Mod, license: value.license, author: Author::Team { @@ -307,17 +306,6 @@ pub async fn get_multiple(ids: &[String]) -> Result> { )?) } -pub async fn list() -> Result> { - 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( query: Option, limit: Option, diff --git a/packages/core/src/constants.rs b/packages/core/src/constants.rs index 9947246a..f29e33b7 100644 --- a/packages/core/src/constants.rs +++ b/packages/core/src/constants.rs @@ -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"; /// / API base url. diff --git a/packages/core/src/store/package.rs b/packages/core/src/store/package.rs index fafe748a..89b117a8 100644 --- a/packages/core/src/store/package.rs +++ b/packages/core/src/store/package.rs @@ -143,7 +143,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 +154,6 @@ pub struct SearchResult { pub follows: u32, pub date_created: DateTime, pub date_modified: DateTime, - #[serde(default)] - pub license: Option, } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -612,7 +610,6 @@ pub struct ManagedPackage { // Core Metadata pub provider: Providers, pub id: String, - pub uid: Option, pub package_type: PackageType, pub title: String, pub description: String, diff --git a/packages/core/src/utils/http.rs b/packages/core/src/utils/http.rs index fad80431..b241815e 100644 --- a/packages/core/src/utils/http.rs +++ b/packages/core/src/utils/http.rs @@ -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, - header: Option<(&str, &str)>, + headers: Option>, ingress: Option<(&IngressId, f64)>, semaphore: &FetchSemaphore, ) -> crate::Result { @@ -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; From f9788ae49b783e8d88b6029a47b59ff522255b96 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Fri, 20 Sep 2024 20:02:11 +0200 Subject: [PATCH 2/8] chore: Merge main --- packages/core/src/api/proxy/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/api/proxy/mod.rs b/packages/core/src/api/proxy/mod.rs index 2c4e5904..cf0a5a2b 100644 --- a/packages/core/src/api/proxy/mod.rs +++ b/packages/core/src/api/proxy/mod.rs @@ -119,7 +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> { Ok(PROXY_STATE.get().ok_or(ProxyError::NotInitialized)?.clone()) From 0fb201792410d77e47cd06ea2c41be42f598182b Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Fri, 20 Sep 2024 22:07:02 +0200 Subject: [PATCH 3/8] feat: Start working on implementing Curseforge into the frontend + fetch_categories helper script --- .template.env | 3 + Cargo.lock | 9 +- Cargo.toml | 2 +- apps/frontend/src/ui/hooks/useBrowser.tsx | 4 +- .../src/ui/pages/browser/BrowserRoot.tsx | 2 +- .../src/ui/pages/browser/BrowserSearch.tsx | 2 +- apps/frontend/src/utils/browser.ts | 139 ++++++++++-------- apps/frontend/src/utils/index.ts | 5 +- packages/core/.template.env | 2 - packages/core/build.rs | 42 +++++- .../src/api/package/content/curseforge.rs | 52 +++---- packages/scripts/api/fetch_categories.ts | 120 +++++++++++++++ packages/scripts/package.json | 5 +- pnpm-lock.yaml | 29 ++++ 14 files changed, 309 insertions(+), 107 deletions(-) create mode 100644 .template.env delete mode 100644 packages/core/.template.env create mode 100644 packages/scripts/api/fetch_categories.ts diff --git a/.template.env b/.template.env new file mode 100644 index 00000000..434d6c54 --- /dev/null +++ b/.template.env @@ -0,0 +1,3 @@ +#! Use single quotes due to special characters and dotenvy being weird + +CURSEFORGE_API_KEY = '' diff --git a/Cargo.lock b/Cargo.lock index 56fd1d0d..9a172198 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -4357,7 +4362,7 @@ dependencies = [ "chrono", "dirs 5.0.1", "discord-rich-presence", - "dotenvy", + "dotenvy 0.15.7 (git+https://github.com/allan2/dotenvy/?rev=db0d6aa8fc8d1b0ddbfe94b5c833372e463eb1c5)", "flate2", "futures", "indicatif", @@ -5153,7 +5158,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", diff --git a/Cargo.toml b/Cargo.toml index 5f78aeda..8276853b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -179,7 +179,7 @@ cocoa = { version = "0.26" } objc = { version = "0.2" } cc = { version = "1.1" } libc = { version = "0.2" } -dotenvy = { version = "0.15.7" } +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" ] } diff --git a/apps/frontend/src/ui/hooks/useBrowser.tsx b/apps/frontend/src/ui/hooks/useBrowser.tsx index 5de57a25..473ca0e5 100644 --- a/apps/frontend/src/ui/hooks/useBrowser.tsx +++ b/apps/frontend/src/ui/hooks/useBrowser.tsx @@ -1,5 +1,3 @@ -import type { Cluster, ManagedPackage, PackageType, Providers, ProviderSearchQuery, ProviderSearchResults } from '@onelauncher/client/bindings'; -import type { ModalProps } from '~ui/components/overlay/Modal'; import { useNavigate } from '@solidjs/router'; import { bridge } from '~imports'; import Button from '~ui/components/base/Button'; @@ -7,6 +5,8 @@ import Dropdown from '~ui/components/base/Dropdown'; import Modal, { 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 type { Cluster, ManagedPackage, PackageType, Providers, ProviderSearchQuery, ProviderSearchResults } from '@onelauncher/client/bindings'; +import type { ModalProps } from '~ui/components/overlay/Modal'; import { useRecentCluster } from './useCluster'; import useCommand, { tryResult } from './useCommand'; diff --git a/apps/frontend/src/ui/pages/browser/BrowserRoot.tsx b/apps/frontend/src/ui/pages/browser/BrowserRoot.tsx index d4896d9e..c2f8a713 100644 --- a/apps/frontend/src/ui/pages/browser/BrowserRoot.tsx +++ b/apps/frontend/src/ui/pages/browser/BrowserRoot.tsx @@ -93,7 +93,7 @@ function BrowserCategories() {
Categories
- + {category => (

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, diff --git a/apps/frontend/src/utils/browser.ts b/apps/frontend/src/utils/browser.ts index cba8f15f..dabfc757 100644 --- a/apps/frontend/src/utils/browser.ts +++ b/apps/frontend/src/utils/browser.ts @@ -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 = { - 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,75 @@ export const browserCategories = { return Object.values(modMapping[provider]); }, }; + +const modMapping: Record = { + Curseforge: [ + { display: 'Server Utility', id: '435' }, + { display: 'Armor, Tools, and Weapons', id: '434' }, + { display: 'API and Library', id: '421' }, + { display: 'Adventure and RPG', id: '422' }, + { display: 'World Gen', id: '406' }, + { display: 'Buildcraft', id: '432' }, + { display: 'Twitch Integration', id: '4671' }, + { display: 'Industrial Craft', id: '429' }, + { display: 'Magic', id: '419' }, + { display: 'Cosmetic', id: '424' }, + { display: 'KubeJS', id: '5314' }, + { display: 'Storage', id: '420' }, + { display: 'Mobs', id: '411' }, + { display: 'Ores and Resources', id: '408' }, + { display: 'Create', id: '6484' }, + { display: 'Genetics', id: '418' }, + { display: 'Food', id: '436' }, + { display: 'Automation', id: '4843' }, + { display: 'Performance', id: '6814' }, + { display: 'Farming', id: '416' }, + { display: 'CraftTweaker', id: '4773' }, + { display: 'Structures', id: '409' }, + { display: 'Map and Information', id: '423' }, + { display: 'Bug Fixes', id: '6821' }, + { display: 'Biomes', id: '407' }, + { display: 'Energy', id: '417' }, + { display: 'Addons', id: '426' }, + { display: 'Blood Magic', id: '4485' }, + { display: 'Energy, Fluid, and Item Transport', id: '415' }, + { display: 'Forestry', id: '433' }, + { display: 'Education', id: '5299' }, + { display: 'Technology', id: '412' }, + { display: 'Thermal Expansion', id: '427' }, + { display: 'Dimensions', id: '410' }, + { display: 'Thaumcraft', id: '430' }, + { display: 'Processing', id: '413' }, + { display: 'Tinker\'s Construct', id: '428' }, + { display: 'Miscellaneous', id: '425' }, + { display: 'Skyblock', id: '6145' }, + { display: 'Galacticraft', id: '5232' }, + { display: 'Player Transport', id: '414' }, + { display: 'Applied Energistics 2', id: '4545' }, + { display: 'Integrated Dynamics', id: '6954' }, + { display: 'Redstone', id: '4558' }, + { display: 'Utility & QoL', id: '5191' }, + { 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' }, + ], +}; diff --git a/apps/frontend/src/utils/index.ts b/apps/frontend/src/utils/index.ts index 932db3e7..b70ba771 100644 --- a/apps/frontend/src/utils/index.ts +++ b/apps/frontend/src/utils/index.ts @@ -1,6 +1,6 @@ -import type { Cluster, ImportType, License, Loader, PackageType, Providers, VersionType } from '@onelauncher/client/bindings'; import { DurationFormat } from '@formatjs/intl-durationformat'; import { open } from '@tauri-apps/plugin-shell'; +import type { Cluster, ImportType, License, Loader, PackageType, Providers, VersionType } from '@onelauncher/client/bindings'; export function setAsyncTimeout(ms: number): Promise; export function setAsyncTimeout(callback: () => void, ms: number): Promise; @@ -163,12 +163,13 @@ export function getLicenseUrl(licenseId: string | License | null | undefined): s export function getPackageUrl(provider: Providers, id: string, package_type: PackageType = 'mod'): string { const mapping: Record = { Modrinth: `https://modrinth.com/${package_type}/${id}`, + Curseforge: `https://www.curseforge.com/minecraft/${package_type}/${id}`, }; return mapping[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; diff --git a/packages/core/.template.env b/packages/core/.template.env deleted file mode 100644 index 1fe71cf6..00000000 --- a/packages/core/.template.env +++ /dev/null @@ -1,2 +0,0 @@ -# You may need to escape any special characters such as $ -CURSEFORGE_API_KEY = "" diff --git a/packages/core/build.rs b/packages/core/build.rs index 4d667801..e57d8343 100644 --- a/packages/core/build.rs +++ b/packages/core/build.rs @@ -1,9 +1,41 @@ -fn main() { - let dotenv_path = dotenvy::dotenv().expect("failed to find .env file"); - println!("cargo:rerun-if-changed={}", dotenv_path.display()); +use std::path::PathBuf; - for env_var in dotenvy::dotenv_iter().unwrap() { - let (key, value) = env_var.unwrap(); +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 = 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}"); } } \ No newline at end of file diff --git a/packages/core/src/api/package/content/curseforge.rs b/packages/core/src/api/package/content/curseforge.rs index d3f1c2ad..43ea08d1 100644 --- a/packages/core/src/api/package/content/curseforge.rs +++ b/packages/core/src/api/package/content/curseforge.rs @@ -71,6 +71,7 @@ impl From for CurseforgeLoader { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] +#[allow(dead_code)] pub struct Pagination { pub index: u32, pub page_size: u32, @@ -89,25 +90,25 @@ pub struct CurseforgePackage { pub slug: String, pub links: Links, pub summary: String, - pub status: u32, + // pub status: u32, pub download_count: u64, pub is_featured: bool, - pub primary_category_id: u32, + // pub primary_category_id: u32, pub categories: Vec, pub authors: Vec, pub logo: Logo, - pub screenshots: Vec, - pub main_file_id: u32, - pub latest_files: Vec, - pub latest_files_indexes: Vec, - pub latest_early_access_files_indexes: Vec, + // pub screenshots: Vec, + // pub main_file_id: u32, + // pub latest_files: Vec, + // pub latest_files_indexes: Vec, + // pub latest_early_access_files_indexes: Vec, pub date_created: DateTime, pub date_modified: DateTime, pub date_released: DateTime, #[serde(default)] pub allow_mod_distribution: bool, - pub game_popularity_rank: u32, - pub is_available: bool, + // pub game_popularity_rank: u32, + // pub is_available: bool, pub thumbs_up_count: u32, pub rating: Option, } @@ -183,7 +184,7 @@ pub struct Category { pub url: String, pub icon_url: String, pub date_modified: String, - pub parent_category_id: u32, + // pub parent_category_id: u32, // pub display_index: u32, } @@ -226,11 +227,11 @@ pub struct LatestFile { pub is_available: bool, pub display_name: String, pub file_name: String, - pub release_type: u32, - pub file_status: u32, - pub hashes: Vec, - pub file_date: String, - pub file_length: u32, + // pub release_type: u32, + // pub file_status: u32, + // pub hashes: Vec, + // pub file_date: String, + // pub file_length: u32, pub download_count: u32, // pub file_size_on_disk: u32, pub download_url: Option, @@ -319,7 +320,7 @@ struct CFData { data: T, } -pub async fn fetch(url: &str, url_builder: Option>) -> Result { +pub async fn fetch_url_builder(url: &str, url_builder: Option) -> Result { // 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"))?; @@ -338,7 +339,7 @@ pub async fn fetch(url: &str, url_builder: Option>) -> http::fetch_advanced( Method::GET, - url.as_str(), + dbg!(url.as_str()), None, None, Some(headers), @@ -348,6 +349,10 @@ pub async fn fetch(url: &str, url_builder: Option>) -> .await } +async fn fetch(url: &str) -> Result { + fetch_url_builder(url, None::).await +} + pub async fn search( query: Option, limit: Option, @@ -364,7 +369,7 @@ pub async fn search( } let results: CFSearchResults = serde_json::from_slice( - &fetch( + &fetch_url_builder( "/v1/mods/search", Some(Box::new(move |url: &mut Url| { let params = &mut url.query_pairs_mut(); @@ -378,16 +383,16 @@ pub async fn search( if let Some(game_versions) = game_versions { // Need to create a list e.g. ["1.16.5", "1.17.1"] - params.append_pair("gameVersions", game_versions.into_iter().map(|v| format!("\"{}\"", v)).collect::>().join(",").as_str()); + params.append_pair("gameVersions", format!("[{}]", game_versions.into_iter().map(|v| format!("\"{}\"", v)).collect::>().join(",")).as_str()); } if let Some(loaders) = loaders { - params.append_pair("modLoaderTypes", loaders.into_iter().map(|l| (CurseforgeLoader::from(l) as u32).to_string()).collect::>().join(",").as_str()); + params.append_pair("modLoaderTypes", format!("[{}]", loaders.into_iter().map(|l| (CurseforgeLoader::from(l) as u32).to_string()).collect::>().join(",")).as_str()); } // TODO: Merge this with categories if let Some(package_types) = package_types { - params.append_pair("categoryIds", package_types.into_iter().map(|p| (CFPackageType::from(p) as u32).to_string()).collect::>().join(",").as_str()); + params.append_pair("categoryIds", format!("[{}]", package_types.into_iter().map(|p| (CFPackageType::from(p) as u32).to_string()).collect::>().join(",")).as_str()); } })) ).await?)?; @@ -401,10 +406,7 @@ pub async fn search( pub async fn get(id: u32) -> Result { Ok(serde_json::from_slice::>( - &fetch( - format!("/v1/mods/{}", id).as_str(), - None, - ) + &fetch(format!("/v1/mods/{}", id).as_str()) .await?, )?.data) } \ No newline at end of file diff --git a/packages/scripts/api/fetch_categories.ts b/packages/scripts/api/fetch_categories.ts new file mode 100644 index 00000000..5cd6ef5d --- /dev/null +++ b/packages/scripts/api/fetch_categories.ts @@ -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; + +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 { + 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 = { + 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 { + 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); +})(); diff --git a/packages/scripts/package.json b/packages/scripts/package.json index fc77e68b..3caaca7e 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ee1a8e3..6c090203 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,9 +264,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 @@ -1020,6 +1026,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==} @@ -4866,6 +4884,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.19.1': {} From c99131946367f1dfbf49d24be1b0197dd9333acb Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 21 Sep 2024 14:53:56 +0200 Subject: [PATCH 4/8] feat: Fix curseforge search and tweak browser frontend --- .../ui/components/content/SearchResults.tsx | 5 +- apps/frontend/src/ui/hooks/useBrowser.tsx | 82 +++++++++++-------- .../src/ui/pages/browser/BrowserMain.tsx | 24 +++--- .../src/ui/pages/browser/BrowserRoot.tsx | 13 +-- .../src/ui/pages/browser/BrowserSearch.tsx | 3 +- apps/frontend/src/utils/browser.ts | 77 ++++++++--------- .../src/api/package/content/curseforge.rs | 28 +++++-- 7 files changed, 131 insertions(+), 101 deletions(-) diff --git a/apps/frontend/src/ui/components/content/SearchResults.tsx b/apps/frontend/src/ui/components/content/SearchResults.tsx index c3b0422f..17192ac5 100644 --- a/apps/frontend/src/ui/components/content/SearchResults.tsx +++ b/apps/frontend/src/ui/components/content/SearchResults.tsx @@ -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) {

-
+
diff --git a/apps/frontend/src/ui/pages/browser/BrowserPackage.tsx b/apps/frontend/src/ui/pages/browser/BrowserPackage.tsx index 163d7f69..17f95fde 100644 --- a/apps/frontend/src/ui/pages/browser/BrowserPackage.tsx +++ b/apps/frontend/src/ui/pages/browser/BrowserPackage.tsx @@ -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'; @@ -15,7 +15,7 @@ import useCommand 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, 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 +23,25 @@ interface BrowserModParams extends Params { provider: Providers; } -const BrowserPackageContext = createContext(null); +interface BrowserPackageContextType { + pkg: Accessor; + body: Resource; +}; + +const BrowserPackageContext = createContext(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 - + {props.children} ); @@ -67,7 +78,6 @@ function NavLink(props: ParentProps & { href: string }) { function BrowserPackage(props: ParentProps) { const [params] = useSearchParams(); 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 +91,7 @@ function BrowserPackage(props: ParentProps) { - +
@@ -108,7 +118,7 @@ function BrowserPackage(props: ParentProps) {
)} - when={pkg() !== undefined && authors() !== undefined} + when={pkg() !== undefined} />
@@ -123,7 +133,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(); @@ -158,10 +169,12 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[] {abbreviateNumber(props.package.downloads)}
-
- - {abbreviateNumber(props.package.followers)} -
+ 0}> +
+ + {abbreviateNumber(props.package.followers)} +
+
@@ -170,7 +183,7 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]

Links

- + {props.package.provider} {' '} Page @@ -179,14 +192,14 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]

Authors

- + {author => ( <>
promptOpen(author.url)} > - {`${author.username}'s + {`${author.username}'s
{author.username} @@ -200,7 +213,7 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
- 1}> + 1)}>
@@ -346,7 +359,7 @@ function BrowserPackageBody() { return (
- +
); } @@ -371,7 +384,7 @@ function BrowserPackageVersions() { const MAX_ITEMS_PER_PAGE = 20; const { page, Navigation } = usePagination({ - itemsCount: () => context?.versions.length || 0, + itemsCount: () => context?.pkg().versions.length || 0, itemsPerPage: () => MAX_ITEMS_PER_PAGE, }); @@ -379,7 +392,7 @@ 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; @@ -392,7 +405,7 @@ function BrowserPackageVersions() { const list = getVersionsForPage(page()); - return await bridge.commands.getProviderPackageVersions(context.provider, list); + return await bridge.commands.getProviderPackageVersions(context.pkg().provider, list); }); let container!: HTMLDivElement; diff --git a/apps/frontend/src/utils/index.ts b/apps/frontend/src/utils/index.ts index b70ba771..aee66ca2 100644 --- a/apps/frontend/src/utils/index.ts +++ b/apps/frontend/src/utils/index.ts @@ -1,6 +1,6 @@ +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'; -import type { Cluster, ImportType, License, Loader, PackageType, Providers, VersionType } from '@onelauncher/client/bindings'; export function setAsyncTimeout(ms: number): Promise; export function setAsyncTimeout(callback: () => void, ms: number): Promise; @@ -160,13 +160,23 @@ 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 = { - Modrinth: `https://modrinth.com/${package_type}/${id}`, - Curseforge: `https://www.curseforge.com/minecraft/${package_type}/${id}`, +export function getPackageUrl(pkg: ManagedPackage): string { + const mapping: Record string> = { + Modrinth: () => `https://modrinth.com/${pkg.package_type}/${pkg.id}`, + Curseforge: () => { + const packageTypeMapping: Record = { + 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; diff --git a/packages/core/src/api/cluster/content/package.rs b/packages/core/src/api/cluster/content/package.rs index ee5457c8..eb8ac777 100644 --- a/packages/core/src/api/cluster/content/package.rs +++ b/packages/core/src/api/cluster/content/package.rs @@ -28,6 +28,8 @@ 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?; diff --git a/packages/core/src/api/package/content/curseforge.rs b/packages/core/src/api/package/content/curseforge.rs index d953fa20..0d3b9dd3 100644 --- a/packages/core/src/api/package/content/curseforge.rs +++ b/packages/core/src/api/package/content/curseforge.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; use url::Url; -use crate::{data::{Loader, ManagedPackage, PackageType}, store::{ProviderSearchResults, SearchResult}, utils::http, Result, State}; +use crate::{data::{Loader, ManagedPackage, ManagedUser, ManagedVersion, PackageType}, store::{ProviderSearchResults, SearchResult}, utils::http, Result, State}; /// Mapping of Curseforge "classes" to their respective category ids #[derive(Debug, Default, Serialize_repr, Deserialize_repr)] @@ -121,23 +121,33 @@ impl From for ManagedPackage { provider: super::Providers::Curseforge, package_type: package.package_type.into(), description: package.summary, - body: "".to_string(), // TODO: Description is a new HTTP request - main: todo!(), - versions: todo!(), - game_versions: todo!(), - loaders: todo!(), - icon_url: todo!(), - created: todo!(), - updated: todo!(), - client: todo!(), - server: todo!(), - downloads: todo!(), - followers: todo!(), - categories: todo!(), - optional_categories: todo!(), - license: todo!(), - author: todo!(), - is_archived: todo!(), + body: crate::store::PackageBody::Url(format!("/v1/mods/{}/description", package.id)), + main: package.slug, + versions: vec![], + game_versions: vec![], + loaders: vec![], + icon_url: Some(package.logo.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, } } } @@ -220,7 +230,7 @@ pub struct Screenshot { #[derive(Default, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct LatestFile { +pub struct ModFile { pub id: u32, pub game_id: u32, pub mod_id: u32, @@ -282,19 +292,7 @@ pub struct Module { #[derive(Default, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct LatestFilesIndex { - 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, -} - -#[derive(Default, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LatestEarlyAccessFilesIndex { +pub struct ModFilesIndex { pub game_version: String, pub file_id: u32, pub filename: String, @@ -423,4 +421,20 @@ pub async fn get(id: u32) -> Result { &fetch(format!("/v1/mods/{}", id).as_str()) .await?, )?.data) -} \ No newline at end of file +} + +pub async fn get_package_body(url: String) -> Result { + Ok(serde_json::from_slice::>( + &fetch(&url).await?, + )?.data) +} + +// pub async fn get_all_versions( +// project_id: &str, +// game_versions: Option>, +// loaders: Option>, +// page: Option, +// page_size: Option, +// ) -> Result> { + +// } \ No newline at end of file diff --git a/packages/core/src/api/package/content/mod.rs b/packages/core/src/api/package/content/mod.rs index fa748f3a..74df8030 100644 --- a/packages/core/src/api/package/content/mod.rs +++ b/packages/core/src/api/package/content/mod.rs @@ -7,7 +7,7 @@ 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::Result; mod modrinth; @@ -107,18 +107,29 @@ impl Providers { project_id: &str, game_versions: Option>, loaders: Option>, + page: Option, + page_size: Option, ) -> Result> { - Ok(match self { - Self::Modrinth => modrinth::get_all_versions(project_id, game_versions, loaders), - Self::Curseforge => todo!(), - } - .await? - .into_iter() - .map(Into::into) - .collect()) + Ok( + match self { + Self::Modrinth => modrinth::get_all_versions(project_id, game_versions, loaders), + // Self::Curseforge => curseforge::get_all_versions( + // project_id, + // game_versions.map(|list| list.first()), + // loaders.map(|list| list.first()), + // page, + // page_size + // ), + Self::Curseforge => todo!(), + } + .await? + .into_iter() + .map(Into::into) + .collect() + ) } - pub async fn get_versions(&self, versions: Vec) -> Result> { + pub async fn get_versions(&self, pkg_id: &str, versions: Vec) -> Result> { Ok(match self { Self::Modrinth => modrinth::get_versions(versions), Self::Curseforge => todo!(), @@ -129,7 +140,7 @@ impl Providers { .collect()) } - pub async fn get_version(&self, version_id: &str) -> Result { + pub async fn get_version(&self, pkg_id: &str, version_id: &str) -> Result { Ok(match self { Self::Modrinth => modrinth::get_version(version_id), Self::Curseforge => todo!(), @@ -139,11 +150,27 @@ impl Providers { } pub async fn get_authors(&self, author: &Author) -> Result> { - match self { - Self::Modrinth => modrinth::get_authors(author), - Self::Curseforge => todo!(), - } - .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 { + 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() + }) } } diff --git a/packages/core/src/api/package/content/modrinth.rs b/packages/core/src/api/package/content/modrinth.rs index 296dc59b..549d6987 100644 --- a/packages/core/src/api/package/content/modrinth.rs +++ b/packages/core/src/api/package/content/modrinth.rs @@ -68,7 +68,7 @@ impl From 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,7 +78,7 @@ impl From 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), diff --git a/packages/core/src/store/package.rs b/packages/core/src/store/package.rs index 89b117a8..5b151af5 100644 --- a/packages/core/src/store/package.rs +++ b/packages/core/src/store/package.rs @@ -603,6 +603,13 @@ pub enum Author { Users(Vec), } +#[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)] @@ -613,7 +620,7 @@ pub struct ManagedPackage { pub package_type: PackageType, pub title: String, pub description: String, - pub body: String, + pub body: PackageBody, pub main: String, pub versions: Vec, pub game_versions: Vec, @@ -624,7 +631,7 @@ pub struct ManagedPackage { pub updated: DateTime, pub client: PackageSide, pub server: PackageSide, - pub downloads: u32, + pub downloads: u64, pub followers: u32, pub categories: Vec, pub optional_categories: Option>, diff --git a/packages/core/src/utils/mod.rs b/packages/core/src/utils/mod.rs index 293d0d95..8b95dcf1 100644 --- a/packages/core/src/utils/mod.rs +++ b/packages/core/src/utils/mod.rs @@ -8,6 +8,7 @@ pub mod http; pub mod java; pub mod watcher; +pub mod pagination; #[cfg(feature = "tauri")] pub mod window; diff --git a/packages/core/src/utils/pagination.rs b/packages/core/src/utils/pagination.rs new file mode 100644 index 00000000..d5cd4f85 --- /dev/null +++ b/packages/core/src/utils/pagination.rs @@ -0,0 +1,10 @@ +pub fn get_page_offset(page: Option, page_size: Option) -> (u32, u16) { + let page = page.unwrap_or(1); + 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 +} \ No newline at end of file From 518c7ec8b9925ba8c7987cf98a2442c05f88aaf5 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Thu, 3 Oct 2024 20:48:01 +0200 Subject: [PATCH 6/8] feat: Implement more functionality for Curseforge --- apps/desktop/src/api/commands/package.rs | 20 +- .../ui/components/content/FormattedLog.tsx | 4 +- .../ui/components/content/ProviderIcon.tsx | 2 +- apps/frontend/src/ui/hooks/usePagination.tsx | 1 + .../src/ui/pages/browser/BrowserPackage.tsx | 30 +- apps/frontend/vite.config.ts | 8 + apps/testing/examples/search_packages.rs | 2 +- packages/core/build.rs | 17 +- .../core/src/api/cluster/content/package.rs | 4 +- .../src/api/package/content/curseforge.rs | 536 ++++++++++++------ packages/core/src/api/package/content/mod.rs | 125 ++-- .../core/src/api/package/content/modrinth.rs | 31 +- packages/core/src/store/package.rs | 44 +- packages/core/src/store/processor.rs | 24 +- packages/core/src/utils/mod.rs | 2 +- packages/core/src/utils/pagination.rs | 17 +- 16 files changed, 579 insertions(+), 288 deletions(-) diff --git a/apps/desktop/src/api/commands/package.rs b/apps/desktop/src/api/commands/package.rs index a646f0a5..53bfa25c 100644 --- a/apps/desktop/src/api/commands/package.rs +++ b/apps/desktop/src/api/commands/package.rs @@ -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, PackageBody, 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>, loaders: Option>, -) -> Result, String> { + page: Option, + page_size: Option, +) -> Result<(Vec, Pagination), String> { Ok(provider - .get_all_versions(&project_id, game_versions, loaders, None, None) // TODO + .get_all_versions(&project_id, game_versions, loaders, page, page_size) .await?) } @@ -61,7 +66,7 @@ pub async fn get_provider_package_versions( provider: Providers, versions: Vec, ) -> Result, String> { - Ok(provider.get_versions("", versions).await?) // TODO + Ok(provider.get_versions(versions).await?) } #[specta::specta] @@ -70,7 +75,7 @@ pub async fn get_provider_package_version( provider: Providers, version: String, ) -> Result { - Ok(provider.get_version("", &version).await?) // TODO + Ok(provider.get_version("", &version).await?) // TODO } #[derive(specta::Type, serde::Deserialize, serde::Serialize)] @@ -116,10 +121,7 @@ pub async fn get_provider_authors( #[specta::specta] #[tauri::command] -pub async fn get_package_body( - provider: Providers, - body: PackageBody -) -> Result { +pub async fn get_package_body(provider: Providers, body: PackageBody) -> Result { Ok(provider.get_package_body(&body).await?) } diff --git a/apps/frontend/src/ui/components/content/FormattedLog.tsx b/apps/frontend/src/ui/components/content/FormattedLog.tsx index 12b8ae72..b789dad3 100644 --- a/apps/frontend/src/ui/components/content/FormattedLog.tsx +++ b/apps/frontend/src/ui/components/content/FormattedLog.tsx @@ -1,8 +1,8 @@ +import type { Ref } from '@solid-primitives/refs'; +import type { OnUpdatedEventListenerArgs, OverlayScrollbars } from 'overlayscrollbars'; import { ChevronDownDoubleIcon } from '@untitled-theme/icons-solid'; import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from 'overlayscrollbars-solid'; import { createEffect, createMemo, createSignal, Index, Show, untrack } from 'solid-js'; -import type { Ref } from '@solid-primitives/refs'; -import type { OnUpdatedEventListenerArgs, OverlayScrollbars } from 'overlayscrollbars'; import Button from '../base/Button'; import styles from './FormattedLog.module.scss'; diff --git a/apps/frontend/src/ui/components/content/ProviderIcon.tsx b/apps/frontend/src/ui/components/content/ProviderIcon.tsx index 4c16a8be..b112319a 100644 --- a/apps/frontend/src/ui/components/content/ProviderIcon.tsx +++ b/apps/frontend/src/ui/components/content/ProviderIcon.tsx @@ -1,6 +1,6 @@ +import type { ManagedPackage, Providers } from '@onelauncher/client/bindings'; import ModrinthImage from '~assets/logos/modrinth.svg?component-solid'; import { type Component, type JSX, Match, Show, splitProps, Switch } from 'solid-js'; -import type { ManagedPackage, Providers } from '@onelauncher/client/bindings'; export function getProviderLogoElement(provider: ManagedPackage | Providers): string | Component { const providerName = (typeof provider === 'string' ? provider : provider.provider)?.toLowerCase() as Lowercase; diff --git a/apps/frontend/src/ui/hooks/usePagination.tsx b/apps/frontend/src/ui/hooks/usePagination.tsx index 1ab2f55a..62274636 100644 --- a/apps/frontend/src/ui/hooks/usePagination.tsx +++ b/apps/frontend/src/ui/hooks/usePagination.tsx @@ -108,6 +108,7 @@ function usePagination(initialOptions: PaginationOptions) { totalPages, offset, reset, + options, Navigation, }; } diff --git a/apps/frontend/src/ui/pages/browser/BrowserPackage.tsx b/apps/frontend/src/ui/pages/browser/BrowserPackage.tsx index 17f95fde..1b91cea0 100644 --- a/apps/frontend/src/ui/pages/browser/BrowserPackage.tsx +++ b/apps/frontend/src/ui/pages/browser/BrowserPackage.tsx @@ -11,11 +11,11 @@ import Tooltip from '~ui/components/base/Tooltip'; import Markdown from '~ui/components/content/Markdown'; import Spinner from '~ui/components/Spinner'; import useBrowser from '~ui/hooks/useBrowser'; -import useCommand from '~ui/hooks/useCommand'; +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 { type Accessor, createContext, createEffect, createSignal, For, Match, on, type ParentProps, type Resource, Show, Switch, useContext } from 'solid-js'; +import { type Accessor, createContext, createEffect, 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 { @@ -148,7 +148,7 @@ function BrowserSidebar(props: { package: ManagedPackage }) {
-

{props.package.title}

+

{props.package.title}

{props.package.package_type} {' '} @@ -383,7 +383,7 @@ function BrowserPackageVersions() { const context = useContext(BrowserPackageContext); const MAX_ITEMS_PER_PAGE = 20; - const { page, Navigation } = usePagination({ + const { page, Navigation, reset, options } = usePagination({ itemsCount: () => context?.pkg().versions.length || 0, itemsPerPage: () => MAX_ITEMS_PER_PAGE, }); @@ -399,13 +399,25 @@ function BrowserPackageVersions() { 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.pkg().provider, list); + return await tryResult(() => bridge.commands.getProviderPackageVersions(context.pkg().provider, list)); }); let container!: HTMLDivElement; @@ -484,8 +496,8 @@ function VersionRow(props: ManagedVersion) {

-

{props.version_id}

-

{props.name}

+

{props.version_display}

+

{props.name === props.version_display ? props.files[0]?.file_name : props.name}

diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 630c407a..1358e436 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -33,6 +33,14 @@ export default defineConfig(async ({ mode }) => { emptyOutDir: false, }, + css: { + preprocessorOptions: { + scss: { + api: 'modern-compiler', + }, + }, + }, + clearScreen: false, server: { port: 8001, diff --git a/apps/testing/examples/search_packages.rs b/apps/testing/examples/search_packages.rs index 6ce50830..58e5f558 100644 --- a/apps/testing/examples/search_packages.rs +++ b/apps/testing/examples/search_packages.rs @@ -31,4 +31,4 @@ async fn search(provider: Providers) -> Result<()> { println!("{:#?}", results); Ok(()) -} \ No newline at end of file +} diff --git a/packages/core/build.rs b/packages/core/build.rs index e57d8343..e7d1fb00 100644 --- a/packages/core/build.rs +++ b/packages/core/build.rs @@ -1,10 +1,7 @@ use std::path::PathBuf; fn main() { - let paths = vec![ - get_root_workspace().join(".env"), - PathBuf::from(".env"), - ]; + let paths = vec![get_root_workspace().join(".env"), PathBuf::from(".env")]; for path in paths { if path.exists() { @@ -33,9 +30,11 @@ fn get_root_workspace() -> PathBuf { } fn load_from_path(path: PathBuf) { - let vars = dotenvy::EnvLoader::with_path(path).load().expect("failed to find .env file"); + 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}"); - } -} \ No newline at end of file + for (key, value) in vars { + println!("cargo:rustc-env={key}={value}"); + } +} diff --git a/packages/core/src/api/cluster/content/package.rs b/packages/core/src/api/cluster/content/package.rs index eb8ac777..49c7158c 100644 --- a/packages/core/src/api/cluster/content/package.rs +++ b/packages/core/src/api/cluster/content/package.rs @@ -33,7 +33,7 @@ pub async fn find_managed_version( ) .await?; - Ok(versions + Ok(versions.0 .iter() .find(|v| { let check_game_version = game_version @@ -46,7 +46,7 @@ pub async fn find_managed_version( let check_package_version = package_version .as_ref() - .map_or(true, |pv| pv == &v.version_id); + .map_or(true, |pv| pv == &v.version_display); check_game_version && check_loader && check_package_version }) diff --git a/packages/core/src/api/package/content/curseforge.rs b/packages/core/src/api/package/content/curseforge.rs index 0d3b9dd3..38008ba8 100644 --- a/packages/core/src/api/package/content/curseforge.rs +++ b/packages/core/src/api/package/content/curseforge.rs @@ -7,7 +7,10 @@ use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; use url::Url; -use crate::{data::{Loader, ManagedPackage, ManagedUser, ManagedVersion, PackageType}, store::{ProviderSearchResults, SearchResult}, utils::http, Result, State}; +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)] @@ -57,6 +60,18 @@ pub enum CurseforgeLoader { NeoForge = 6, } +impl From 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 for CurseforgeLoader { fn from(loader: Loader) -> Self { match loader { @@ -69,48 +84,50 @@ impl From for CurseforgeLoader { } } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -#[allow(dead_code)] -pub struct Pagination { - pub index: u32, - pub page_size: u32, - pub result_count: u32, - pub total_count: u32, +impl Into 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, + 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, - pub is_featured: bool, - // pub primary_category_id: u32, - pub categories: Vec, - pub authors: Vec, - pub logo: Logo, - // pub screenshots: Vec, - // pub main_file_id: u32, - // pub latest_files: Vec, - // pub latest_files_indexes: Vec, - // pub latest_early_access_files_indexes: Vec, - pub date_created: DateTime, - pub date_modified: DateTime, - pub date_released: DateTime, + pub name: String, + pub slug: String, + pub links: Links, + pub summary: String, + // pub status: u32, + pub download_count: u64, + pub is_featured: bool, + // pub primary_category_id: u32, + pub categories: Vec, + pub authors: Vec, + pub logo: Logo, + // pub screenshots: Vec, + // pub main_file_id: u32, + // pub latest_files: Vec, + // pub latest_files_indexes: Vec, + // pub latest_early_access_files_indexes: Vec, + pub date_created: DateTime, + pub date_modified: DateTime, + pub date_released: DateTime, #[serde(default)] - pub allow_mod_distribution: bool, - // pub game_popularity_rank: u32, - // pub is_available: bool, - pub thumbs_up_count: u32, - pub rating: Option, + pub allow_mod_distribution: bool, + // pub game_popularity_rank: u32, + // pub is_available: bool, + pub thumbs_up_count: u32, + pub rating: Option, } impl From for ManagedPackage { @@ -137,15 +154,19 @@ impl From for ManagedPackage { 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() + 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, } @@ -158,7 +179,11 @@ impl Into for CurseforgePackage { 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")), + 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, @@ -178,134 +203,223 @@ impl Into for CurseforgePackage { #[derive(Default, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Links { - pub website_url: Option, - pub wiki_url: Option, - pub issues_url: Option, - pub source_url: Option, + pub website_url: Option, + pub wiki_url: Option, + pub issues_url: Option, + pub source_url: Option, } #[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, + 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, + 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, + 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, + 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)] +#[derive(Default, Serialize_repr, Deserialize_repr, Clone, Debug)] +#[repr(u8)] +pub enum ModFileReleaseType { + #[default] + Release = 1, + Beta = 2, + Alpha = 3, +} + +impl Into 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, - pub is_available: bool, - pub display_name: String, - pub file_name: String, - // pub release_type: u32, - // pub file_status: u32, - // pub hashes: Vec, - // pub file_date: String, - // pub file_length: u32, - pub download_count: u32, - // pub file_size_on_disk: u32, - pub download_url: Option, - pub game_versions: Vec, - pub sortable_game_versions: Vec, - pub dependencies: Vec, - // 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, + pub id: u32, + pub game_id: u32, + pub mod_id: u32, + pub is_available: bool, + pub display_name: String, + pub file_name: String, + pub release_type: ModFileReleaseType, + // pub file_status: u32, + pub hashes: Vec, + pub file_date: DateTime, + pub file_length: u64, + pub download_count: u32, + pub file_size_on_disk: Option, + pub download_url: Option, + pub game_versions: Vec, + pub sortable_game_versions: Vec, + pub dependencies: Vec, + // 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, } -#[derive(Default, Debug, Serialize, Deserialize)] +impl Into 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::::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: u32, + 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, + 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, + 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, + 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, + 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, + pub mod_loader: CurseforgeLoader, } // https://docs.curseforge.com/rest-api/?shell#tocS_ModsSearchSortField #[allow(dead_code)] enum SearchSort { - Featured = 1, + Featured = 1, Popularity = 2, LastUpdated = 3, Name = 4, @@ -319,28 +433,38 @@ struct CFData { data: T, } -pub async fn fetch_url_builder(url: &str, url_builder: Option) -> Result { - // 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"))?; +#[derive(Default, Debug, Serialize, Deserialize)] +struct CFPaginatedData { + pagination: Pagination, + data: T, +} - let mut headers = HashMap::<&str, &str>::new(); +pub async fn fetch_advanced( + method: Method, + url: &str, + url_builder: Option, + headers: Option>, + json_body: Option, +) -> Result { + // 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())?; + 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::GET, + method, url.as_str(), None, - None, + json_body, Some(headers), None, &State::get().await?.fetch_semaphore, @@ -348,6 +472,13 @@ pub async fn fetch_url_builder(url: &str, url_builder: Opti .await } +pub async fn fetch_url_builder( + url: &str, + url_builder: Option, +) -> Result { + fetch_advanced(Method::GET, url, url_builder, None, None).await +} + async fn fetch(url: &str) -> Result { fetch_url_builder(url, None::).await } @@ -362,11 +493,7 @@ pub async fn search( loaders: Option>, package_types: Option>, ) -> Result { - #[derive(Deserialize)] - struct CFSearchResults { - pagination: Pagination, - data: Vec - } + type CFSearchResults = CFPaginatedData>; let results: CFSearchResults = serde_json::from_slice( &fetch_url_builder( @@ -374,10 +501,16 @@ pub async fn 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( + "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( + "sortField", + (SearchSort::Popularity as u8).to_string().as_str(), + ); params.append_pair("sortOrder", "desc"); if let Some(query) = query { @@ -386,11 +519,33 @@ pub async fn search( 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::>().join(",")).as_str()); + params.append_pair( + "gameVersions", + format!( + "[{}]", + game_versions + .into_iter() + .map(|v| format!("\"{}\"", v)) + .collect::>() + .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::>().join(",")).as_str()); + params.append_pair( + "modLoaderTypes", + format!( + "[{}]", + loaders + .into_iter() + .map(|l| (CurseforgeLoader::from(l) as u32).to_string()) + .collect::>() + .join(",") + ) + .as_str(), + ); } if let Some(package_types) = package_types { @@ -399,42 +554,105 @@ pub async fn search( } if package_types.len() > 1 { - tracing::warn!("curseforge provider does not support multiple package types"); + 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()); + params.append_pair( + "classId", + (CFPackageType::from(*package_type) as u32) + .to_string() + .as_str(), + ); } - })) - ).await?)?; + })), + ) + .await?, + )?; Ok(ProviderSearchResults { total: results.pagination.total_count, - results: results.data.into_iter().map(Into::into).collect::>(), + results: results + .data + .into_iter() + .map(Into::into) + .collect::>(), provider: crate::package::content::Providers::Curseforge, }) } pub async fn get(id: u32) -> Result { - Ok(serde_json::from_slice::>( - &fetch(format!("/v1/mods/{}", id).as_str()) - .await?, - )?.data) + Ok( + serde_json::from_slice::>(&fetch(format!("/v1/mods/{}", id).as_str()).await?)? + .data, + ) } pub async fn get_package_body(url: String) -> Result { - Ok(serde_json::from_slice::>( - &fetch(&url).await?, - )?.data) + Ok(serde_json::from_slice::>(&fetch(&url).await?)?.data) +} + +pub async fn get_all_versions( + project_id: &str, + game_versions: Option>, + loaders: Option>, + page: Option, + page_size: Option, +) -> Result<(Vec, Pagination)> { + let data: CFPaginatedData> = serde_json::from_slice::>( + &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, +) -> Result> { + let mut map = serde_json::Map::new(); + map.insert("fileIds".to_string(), versions.iter().map(|v| v.parse::().unwrap()).collect::>().into()); + + let json_body = serde_json::Value::Object(map); + + Ok(serde_json::from_slice::>( + &fetch_advanced( + Method::POST, + format!("/v1/mods/files").as_str(), + None::, + None, + Some(json_body) + ) + .await?, + )? + .data) } -// pub async fn get_all_versions( -// project_id: &str, -// game_versions: Option>, -// loaders: Option>, -// page: Option, -// page_size: Option, -// ) -> Result> { -// } \ No newline at end of file diff --git a/packages/core/src/api/package/content/mod.rs b/packages/core/src/api/package/content/mod.rs index 74df8030..110c1a3d 100644 --- a/packages/core/src/api/package/content/mod.rs +++ b/packages/core/src/api/package/content/mod.rs @@ -8,10 +8,11 @@ use serde::{Deserialize, Serialize}; use crate::data::{Loader, ManagedPackage, ManagedUser, ManagedVersion, PackageType}; use crate::package::content::modrinth::FacetBuilder; use crate::store::{Author, PackageBody, ProviderSearchResults}; +use crate::utils::pagination::Pagination; use crate::Result; -mod modrinth; mod curseforge; +mod modrinth; /// Providers for content packages #[cfg_attr(feature = "specta", derive(specta::Type))] @@ -59,35 +60,49 @@ impl Providers { open_source: Option, ) -> Result { match self { - Self::Modrinth => modrinth::search( - query, - limit, - offset, - Some(|mut builder| build_facets( - &mut builder, - categories, + 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, - open_source - )), - ).await, - Self::Curseforge => curseforge::search( - query, - limit, - offset, - game_versions, - categories, - loaders, - package_types - ).await + ) + .await + } } } pub async fn get(&self, slug_or_id: &str) -> Result { Ok(match self { Self::Modrinth => modrinth::get(slug_or_id).await?.into(), - Self::Curseforge => curseforge::get(slug_or_id.parse::().map_err(|err| anyhow::anyhow!(err))?).await?.into(), + Self::Curseforge => curseforge::get( + slug_or_id + .parse::() + .map_err(|err| anyhow::anyhow!(err))?, + ) + .await? + .into(), }) } @@ -109,35 +124,33 @@ impl Providers { loaders: Option>, page: Option, page_size: Option, - ) -> Result> { - Ok( - match self { - Self::Modrinth => modrinth::get_all_versions(project_id, game_versions, loaders), - // Self::Curseforge => curseforge::get_all_versions( - // project_id, - // game_versions.map(|list| list.first()), - // loaders.map(|list| list.first()), - // page, - // page_size - // ), - Self::Curseforge => todo!(), + ) -> Result<(Vec, Pagination)> { + Ok(match self { + 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) } - .await? - .into_iter() - .map(Into::into) - .collect() - ) + + 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, pkg_id: &str, versions: Vec) -> Result> { + pub async fn get_versions(&self, versions: Vec) -> Result> { Ok(match self { - Self::Modrinth => modrinth::get_versions(versions), - Self::Curseforge => todo!(), - } - .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, pkg_id: &str, version_id: &str) -> Result { @@ -151,25 +164,21 @@ impl Providers { pub async fn get_authors(&self, author: &Author) -> Result> { 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::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() + Author::Users(users) => users.to_owned(), }) } pub async fn get_package_body(&self, body: &PackageBody) -> Result { 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::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() + PackageBody::Markdown(markdown) => markdown.to_owned(), }) } } diff --git a/packages/core/src/api/package/content/modrinth.rs b/packages/core/src/api/package/content/modrinth.rs index 549d6987..24407aa7 100644 --- a/packages/core/src/api/package/content/modrinth.rs +++ b/packages/core/src/api/package/content/modrinth.rs @@ -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}; @@ -145,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, } @@ -171,15 +171,16 @@ impl From 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 @@ -439,7 +440,9 @@ pub async fn get_all_versions( project_id: &str, game_versions: Option>, loaders: Option>, -) -> Result> { + page: Option, + page_size: Option, +) -> Result<(Vec, 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( @@ -467,14 +470,26 @@ pub async fn get_all_versions( ); } - Ok(serde_json::from_slice( + let data = serde_json::from_slice::>( &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) -> Result> { diff --git a/packages/core/src/store/package.rs b/packages/core/src/store/package.rs index 5b151af5..a719f9cf 100644 --- a/packages/core/src/store/package.rs +++ b/packages/core/src/store/package.rs @@ -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)] @@ -554,7 +552,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, @@ -662,15 +660,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, pub published: DateTime, pub downloads: u32, - pub version_type: String, + pub version_type: ManagedVersionReleaseType, pub files: Vec, + pub is_available: bool, pub deps: Vec, pub game_versions: Vec, pub loaders: Vec, @@ -683,6 +682,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 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)] @@ -691,7 +723,7 @@ pub struct ManagedVersionFile { pub file_name: String, pub primary: bool, - pub size: u32, + pub size: u64, pub file_type: Option, pub hashes: HashMap, } diff --git a/packages/core/src/store/processor.rs b/packages/core/src/store/processor.rs index fac6d534..9a527249 100644 --- a/packages/core/src/store/processor.rs +++ b/packages/core/src/store/processor.rs @@ -297,13 +297,7 @@ impl Processor { if let Some(process) = self.get(key) { let process = process.clone(); let process = process.write().await; - if process - .current_child - .write() - .await - .try_wait()? - .is_none() - { + if process.current_child.write().await.try_wait()?.is_none() { keys.push(key); } } @@ -334,13 +328,7 @@ impl Processor { if let Some(process) = self.get(key) { let process = process.clone(); let process = process.write().await; - if process - .current_child - .write() - .await - .try_wait()? - .is_none() - { + if process.current_child.write().await.try_wait()?.is_none() { clusters.push(process.cluster_path.clone()); } } @@ -355,13 +343,7 @@ impl Processor { if let Some(process) = self.get(key) { let process = process.clone(); let process = process.write().await; - if process - .current_child - .write() - .await - .try_wait()? - .is_none() - { + if process.current_child.write().await.try_wait()?.is_none() { if let Some(cluster) = cluster::get(&process.cluster_path.clone()).await? { clusters.push(cluster); } diff --git a/packages/core/src/utils/mod.rs b/packages/core/src/utils/mod.rs index 8b95dcf1..67eafca0 100644 --- a/packages/core/src/utils/mod.rs +++ b/packages/core/src/utils/mod.rs @@ -7,8 +7,8 @@ pub mod http; pub mod java; -pub mod watcher; pub mod pagination; +pub mod watcher; #[cfg(feature = "tauri")] pub mod window; diff --git a/packages/core/src/utils/pagination.rs b/packages/core/src/utils/pagination.rs index d5cd4f85..da740228 100644 --- a/packages/core/src/utils/pagination.rs +++ b/packages/core/src/utils/pagination.rs @@ -1,5 +1,18 @@ +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, page_size: Option) -> (u32, u16) { - let page = page.unwrap_or(1); + 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) @@ -7,4 +20,4 @@ pub fn get_page_offset(page: Option, page_size: Option) -> (u32, u16) pub fn get_page_count(total: u32, page_size: u16) -> u32 { (total as f64 / page_size as f64).ceil() as u32 -} \ No newline at end of file +} From a1f8df7f494198caac819ed1859a98862ba76404 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 8 Oct 2024 18:06:43 +0200 Subject: [PATCH 7/8] feat: Finished Curseforge implementation? --- apps/desktop/src/api/commands/package.rs | 1 + apps/frontend/src/ui/hooks/useBrowser.tsx | 103 ++++--------- apps/frontend/src/ui/hooks/useCluster.tsx | 85 ++++++++++- .../src/ui/pages/browser/BrowserPackage.tsx | 138 ++++++++++++------ .../src/ui/pages/cluster/ClusterMods.tsx | 63 ++++---- .../core/src/api/cluster/content/package.rs | 10 +- .../src/api/package/content/curseforge.rs | 31 +++- packages/core/src/api/package/content/mod.rs | 37 ++++- 8 files changed, 298 insertions(+), 170 deletions(-) diff --git a/apps/desktop/src/api/commands/package.rs b/apps/desktop/src/api/commands/package.rs index 53bfa25c..b47b4ce4 100644 --- a/apps/desktop/src/api/commands/package.rs +++ b/apps/desktop/src/api/commands/package.rs @@ -149,6 +149,7 @@ pub async fn download_provider_package( package_version, ) .await?; + package::add_package( &cluster.cluster_path(), pkg_path, diff --git a/apps/frontend/src/ui/hooks/useBrowser.tsx b/apps/frontend/src/ui/hooks/useBrowser.tsx index a109dacb..98fa585c 100644 --- a/apps/frontend/src/ui/hooks/useBrowser.tsx +++ b/apps/frontend/src/ui/hooks/useBrowser.tsx @@ -1,15 +1,12 @@ import type { Cluster, ManagedPackage, PackageType, Providers, ProviderSearchQuery, SearchResult } from '@onelauncher/client/bindings'; -import type { ModalProps } from '~ui/components/overlay/Modal'; 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 { PROVIDERS } from '~utils'; -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 { 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; @@ -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 => ( currentlySelectedCluster(list) || 0} {...props} /> )); @@ -163,73 +182,3 @@ export default function useBrowser() { return controller; } - -function ChooseClusterModal(props: ModalProps) { - const [selected, setSelected] = createSignal(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 ( - , -
} - when={clusters !== undefined} - > - - - {cluster => ( - {cluster.meta.name} - )} - - - - )} - title="Choose a cluster" - /> - ); -} diff --git a/apps/frontend/src/ui/hooks/useCluster.tsx b/apps/frontend/src/ui/hooks/useCluster.tsx index 95dcb9ec..6cc4b83b 100644 --- a/apps/frontend/src/ui/hooks/useCluster.tsx +++ b/apps/frontend/src/ui/hooks/useCluster.tsx @@ -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>; @@ -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(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 ( + , +
- )} - class="max-w-full flex-1 rounded-r-none!" - disabled={filtered()?.length === 0} - iconLeft={} - onClick={download} - /> - - ( -
)} - - -
+ class="max-w-full flex-1 rounded-r-none!" + iconLeft={} + onClick={download} + /> + + ( +