From 0a77a31846bca329ca99e79f310d7ceabaab5182 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 21 Sep 2025 17:04:38 +0200 Subject: [PATCH] feat: oneclient bundles --- apps/oneclient/desktop/src/api/commands.rs | 29 ++ apps/oneclient/desktop/src/constants.rs | 10 +- .../desktop/src/oneclient/bundles.rs | 135 ++++++ .../desktop/src/oneclient/clusters.rs | 113 +++++ apps/oneclient/desktop/src/oneclient/mod.rs | 74 +--- apps/oneclient/frontend/src/bindings.gen.ts | 30 +- ...PackageItem.tsx => BrowserPackageItem.tsx} | 0 .../frontend/src/components/index.ts | 1 + .../components/overlay/PolyBundleModal.tsx | 14 + .../src/routes/app/cluster/browser/index.tsx | 3 +- .../core/src/api/packages/modpack/data.rs | 20 +- packages/core/src/api/packages/modpack/mod.rs | 75 +++- .../core/src/api/packages/modpack/mrpack.rs | 397 +++++++++++------- .../src/api/packages/modpack/polymrpack.rs | 322 ++++++++++++++ packages/core/src/api/tauri/commands.rs | 71 +++- packages/core/src/utils/http.rs | 44 +- rust-toolchain.toml | 2 +- 17 files changed, 1069 insertions(+), 271 deletions(-) create mode 100644 apps/oneclient/desktop/src/oneclient/bundles.rs create mode 100644 apps/oneclient/desktop/src/oneclient/clusters.rs rename apps/oneclient/frontend/src/components/{PackageItem.tsx => BrowserPackageItem.tsx} (100%) create mode 100644 apps/oneclient/frontend/src/components/overlay/PolyBundleModal.tsx create mode 100644 packages/core/src/api/packages/modpack/polymrpack.rs diff --git a/apps/oneclient/desktop/src/api/commands.rs b/apps/oneclient/desktop/src/api/commands.rs index 6a61bf58..6eebf438 100644 --- a/apps/oneclient/desktop/src/api/commands.rs +++ b/apps/oneclient/desktop/src/api/commands.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; +use onelauncher_core::api::cluster::dao::ClusterId; +use onelauncher_core::api::packages::modpack::data::ModpackArchive; use onelauncher_core::entity::clusters; +use onelauncher_core::entity::loader::GameLoader; use onelauncher_core::error::LauncherResult; use tauri::Runtime; @@ -11,6 +14,9 @@ pub trait OneClientApi { #[taurpc(alias = "getClustersGroupedByMajor")] async fn get_clusters_grouped_by_major() -> LauncherResult>>; + + #[taurpc(alias = "getModpacksFor")] + async fn get_modpacks_for(cluster_id: ClusterId) -> LauncherResult>; } #[taurpc::ipc_type] @@ -47,4 +53,27 @@ impl OneClientApi for OneClientApiImpl { Ok(mapped) } + + async fn get_modpacks_for(self, cluster_id: ClusterId) -> LauncherResult> { + struct ModpackEntry { + pub category: String, + pub version: String, + pub loader: GameLoader, + } + + // const ENTRIES: &[ModpackEntry] = &[ + // ModpackEntry { + // category: "Gameplay".into(), + // version: "1.0.0".into(), + // loader: GameLoader::Fabric, + // }, + // ModpackEntry { + // category: "Graphics".into(), + // version: "1.1.0".into(), + // loader: GameLoader::Forge, + // }, + // ]; + + Ok(vec![]) + } } diff --git a/apps/oneclient/desktop/src/constants.rs b/apps/oneclient/desktop/src/constants.rs index 6ccdf0c6..86147321 100644 --- a/apps/oneclient/desktop/src/constants.rs +++ b/apps/oneclient/desktop/src/constants.rs @@ -1,3 +1,9 @@ // === API === -pub const CURSEFORGE_API_KEY: &str = "$2a$10$6utA1UNSmFPrE/Lh7b7ndeeGmiOkjKNY8kpFB0fsmE/d42ZAfFgCe"; -pub const DISCORD_CLIENT_ID: &str = "1274084193193955408"; +pub const CURSEFORGE_API_KEY: &'static str = + "$2a$10$6utA1UNSmFPrE/Lh7b7ndeeGmiOkjKNY8kpFB0fsmE/d42ZAfFgCe"; +pub const DISCORD_CLIENT_ID: &'static str = "1274084193193955408"; + +// === Meta === +// Cannot contain trailing slash +pub const META_URL_BASE: &'static str = + "https://raw.githubusercontent.com/LynithDev/DataStorage/refs/heads/main/oneclient"; diff --git a/apps/oneclient/desktop/src/oneclient/bundles.rs b/apps/oneclient/desktop/src/oneclient/bundles.rs new file mode 100644 index 00000000..f94cea1b --- /dev/null +++ b/apps/oneclient/desktop/src/oneclient/bundles.rs @@ -0,0 +1,135 @@ +use std::collections::HashMap; + +use onelauncher_core::api::packages::modpack::data::ModpackArchive; +use onelauncher_core::entity::loader::GameLoader; +use onelauncher_core::error::LauncherResult; +use onelauncher_core::send_error; +use onelauncher_core::store::Dirs; +use onelauncher_core::utils::{http, io}; +use reqwest::Method; +use tokio::sync::{OnceCell, RwLock}; + +/// e.g. +/// ```json +/// { +/// "versions": { +/// "1.21.5": { +/// "fabric": ["/generated/hud-fabric-1.21.5.mrpack"], +/// "forge": ["/generated/hud-forge-1.21.5.mrpack"] +/// } +/// }, +/// } +/// ``` +#[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)] +struct BundleManifest { + pub versions: HashMap>>, +} + +static BUNDLES_STATE: OnceCell = OnceCell::const_new(); + +#[derive(Debug)] +pub struct BundlesManager { + manifest: RwLock, + bundles: RwLock>>>, +} + +impl BundlesManager { + pub async fn get() -> &'static Self { + BUNDLES_STATE + .get_or_init(|| async { + let manifest = Self::fetch_cached().await; + + Self { + manifest: RwLock::new(manifest), + bundles: RwLock::new(HashMap::new()), + } + }) + .await + } + + #[tracing::instrument] + pub async fn get_bundles_for( + &self, + mc_version: &str, + loader: onelauncher_core::entity::loader::GameLoader, + ) -> LauncherResult> { + let manifest = self.manifest.read().await; + let mut bundles_lock = self.bundles.write().await; + + if let Some(entry) = bundles_lock.get(mc_version) { + if let Some(bundles) = entry.get(&loader) { + return Ok(bundles.clone()); + } + } + + let mut found = Vec::new(); + + // for (version, loaders) in &manifest.versions { + // if version != mc_version { + // continue; + // } + + // let Some(paths) = loaders.get(&loader.get_format_name()) else { + // continue; + // }; + + // // we will be first checking the disk cache, if that fails we fetch from remote + // for path in paths { + // let url = format!("{}{}", crate::constants::META_URL_BASE, path); + // let res = http::request(Method::HEAD, &url, None).await?; + + // // res. + + // let name = path.split('/').last().unwrap_or(&format!("bundle-{}-{}-{}.mrpack")); + // } + + // } + + Ok(found.clone()) + } + + /// Fetches the bundles manifest from remote, falling back to a saved copy on disk if available + #[tracing::instrument] + pub async fn fetch_cached() -> BundleManifest { + let url = format!("{}/bundles.json", crate::constants::META_URL_BASE); + let manifest_path = Self::dir().await.join("bundles.json"); + + match http::fetch_json::(Method::GET, &url, None, None).await { + Ok(manifest) => { + if let Err(e) = io::write_json(&manifest_path, &manifest).await { + send_error!("failed to cache bundles manifest to disk: {e}"); + } + + manifest + } + Err(e) if manifest_path.exists() => { + tracing::debug!( + "falling back to cached bundles manifest, due to error fetching remote: {e}" + ); + + match io::read_json::(&manifest_path).await { + Ok(manifest) => manifest, + Err(e) => { + tracing::error!("failed to read cached bundles manifest: {e}"); + + BundleManifest::default() + } + } + } + Err(e) => { + tracing::error!("failed to fetch bundles manifest from remote: {e}"); + + BundleManifest::default() + } + } + } + + /// returns the directory for everything bundle related + pub async fn dir() -> std::path::PathBuf { + Dirs::get_caches_dir() + .await + .expect("failed to get caches dir") + .join("oneclient") + .join("bundles") + } +} diff --git a/apps/oneclient/desktop/src/oneclient/clusters.rs b/apps/oneclient/desktop/src/oneclient/clusters.rs new file mode 100644 index 00000000..75fbf5f6 --- /dev/null +++ b/apps/oneclient/desktop/src/oneclient/clusters.rs @@ -0,0 +1,113 @@ +use std::path::Path; + +use onelauncher_core::entity::clusters::Model as ClusterModel; +use onelauncher_core::entity::loader::GameLoader; +use onelauncher_core::error::LauncherResult; +use onelauncher_core::send_error; +use onelauncher_core::utils::http::fetch_json; +use reqwest::Method; +use serde::Deserialize; + +/// +/// e.g. +/// ```json +/// { +/// "clusters": [ +/// { +/// "major_version": 21, +/// "entries": [ +/// { +/// "minor_version": 5, +/// "loader": "fabric" +/// }, +/// { +/// "minor_version": 5, +/// "loader": "forge" +/// } +/// ] +/// }, +/// { +/// "major_version": 20, +/// "entries": [ +/// { +/// "minor_version": 5, +/// "loader": "fabric" +/// } +/// ] +/// } +/// ] +/// } +/// ``` +#[derive(Deserialize)] +struct OnlineClusterManifest { + clusters: Vec, +} + +#[derive(Deserialize)] +struct OnlineCluster { + major_version: u8, + entries: Vec, +} + +#[derive(Deserialize)] +struct OnlineClusterEntry { + minor_version: u8, + loader: GameLoader, +} + +pub async fn init_clusters() -> LauncherResult<()> { + let manifest = match fetch_json::( + Method::GET, + &format!("{}/versions/versions.json", crate::constants::META_URL_BASE), + None, + None, + ) + .await + { + Ok(m) => m, + Err(e) => { + send_error!("failed to fetch clusters manifest: {}", e); + return Err(e); + } + }; + + for cluster in manifest.clusters { + for entry in cluster.entries { + let mc_version = format!("1.{}.{}", cluster.major_version, entry.minor_version); + + if let Err(e) = create_cluster_if_not_exist(&mc_version, entry.loader, None).await { + send_error!("failed to create cluster for {}: {}", mc_version, e); + } + } + } + + Ok(()) +} + +fn cluster_name(mc_version: &str, loader: &GameLoader) -> String { + format!("{mc_version} {loader}") +} + +async fn create_cluster_if_not_exist( + mc_version: &str, + mc_loader: GameLoader, + mc_loader_version: Option<&str>, +) -> LauncherResult> { + let name = cluster_name(mc_version, &mc_loader); + + let cluster = + onelauncher_core::api::cluster::dao::get_cluster_by_folder_name(Path::new(&name)).await?; + if cluster.is_some() { + return Ok(cluster); + } + + onelauncher_core::api::cluster::create_cluster( + &name, + mc_version, + mc_loader, + mc_loader_version, + None, + ) + .await + .map(Some) +} diff --git a/apps/oneclient/desktop/src/oneclient/mod.rs b/apps/oneclient/desktop/src/oneclient/mod.rs index d0be6292..fba8e420 100644 --- a/apps/oneclient/desktop/src/oneclient/mod.rs +++ b/apps/oneclient/desktop/src/oneclient/mod.rs @@ -1,74 +1,6 @@ -use onelauncher_core::entity::clusters::Model as ClusterModel; -use onelauncher_core::entity::icon::Icon; -use onelauncher_core::entity::loader::GameLoader; -use onelauncher_core::error::LauncherResult; -use onelauncher_core::send_error; +pub mod bundles; +pub mod clusters; pub async fn initialize_oneclient() { - init_clusters().await; -} - -struct OnlineCluster { - mc_major_version: u8, - mc_minor_versions: Vec, - mc_loaders: Vec, -} - -async fn init_clusters() { - // TODO: use online metadata stuff - let online_clusters: Vec = vec![ - OnlineCluster { - mc_major_version: 21, - mc_minor_versions: vec![1, 2, 4, 7], - mc_loaders: vec![GameLoader::Fabric, GameLoader::Forge], - }, - OnlineCluster { - mc_major_version: 8, - mc_minor_versions: vec![9], - mc_loaders: vec![GameLoader::Forge], - }, - ]; - - for cluster in online_clusters { - for minor_version in cluster.mc_minor_versions { - let mc_version = format!("1.{}.{}", cluster.mc_major_version, minor_version); - - for loader in &cluster.mc_loaders { - if let Err(e) = create_cluster_if_not_exist( - &format!("{mc_version} {loader}"), - &mc_version, - *loader, - None, - None, - ) - .await - { - send_error!("failed to create cluster for {}: {}", mc_version, e); - } - } - } - } -} - -async fn create_cluster_if_not_exist( - name: &str, - mc_version: &str, - mc_loader: GameLoader, - mc_loader_version: Option<&str>, - icon_url: Option, -) -> LauncherResult> { - let clusters = onelauncher_core::api::cluster::dao::get_clusters_by_version(mc_version).await?; - if clusters.len() > 1 { - return Ok(None); - } - - onelauncher_core::api::cluster::create_cluster( - name, - mc_version, - mc_loader, - mc_loader_version, - icon_url, - ) - .await - .map(Some) + clusters::init_clusters().await; } diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index 8bf45ac8..565c6244 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -24,6 +24,8 @@ export type DirectoryError = { type: "BaseDir"; data: string } export type DiscordError = { type: "MissingClientId"; data: string } | { type: "ConnectError"; data: string } +export type ExternalPackage = { name: string; url: string; sha1: string; size: number; package_type: PackageType } + export type Filters = { game_versions: string[] | null; loaders: GameLoader[] | null; categories: PackageCategories | null; package_type: PackageType | null } export type GameLoader = "vanilla" | "forge" | "neoforge" | "quilt" | "fabric" | "legacyfabric" @@ -94,6 +96,16 @@ refresh_token: string; */ expires: string } +export type ModpackArchive = { manifest: ModpackManifest; path: string; format: ModpackFormat } + +export type ModpackFile = { enabled: boolean; kind: ModpackFileKind } + +export type ModpackFileKind = { Managed: [ManagedPackage, ManagedVersion] } | { External: ExternalPackage } + +export type ModpackFormat = "CurseForge" | "MrPack" | "PolyMrPack" + +export type ModpackManifest = { name: string; version: string; loader: GameLoader; loader_version: string; mc_version: string; files: ModpackFile[] } + export type MojangPlayerProfile = { uuid: string; username: string; is_slim: boolean; skin_url: string | null; cape_url: string | null } export type PackageAuthor = { Team: { team_id: string; org_id: string | null } } | { Users: ManagedUser[] } @@ -232,11 +244,8 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'core':'{"getUser":["uuid"],"readSettings":[],"searchPackages":["provider","query"],"writeSettings":["setting"],"createCluster":["options"],"fetchMinecraftProfile":["uuid"],"getLoadersForVersion":["mc_version"],"launchCluster":["id","uuid"],"getGlobalProfile":[],"openMsaLogin":[],"open":["input"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"isClusterRunning":["cluster_id"],"getGameVersions":[],"getMultiplePackages":["provider","slugs"],"setDefaultUser":["uuid"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getLogs":["id"],"getRunningProcesses":[],"getUsersFromAuthor":["provider","author"],"getClusterById":["id"],"getScreenshots":["id"],"getProfileOrDefault":["name"],"getPackageBody":["provider","body"],"getRunningProcessesByClusterId":["cluster_id"],"updateClusterProfile":["name","profile"],"getPackage":["provider","slug"],"removeUser":["uuid"],"updateClusterById":["id","request"],"getWorlds":["id"],"getLogByName":["id","name"],"killProcess":["pid"],"getClusters":[],"removeCluster":["id"],"getDefaultUser":["fallback"],"getUsers":[]}', 'events':'{"message":["event"],"ingress":["event"],"process":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"openDevTools":[]}' } -export type Router = { 'events': { ingress: (event: IngressPayload) => Promise, -message: (event: MessagePayload) => Promise, -process: (event: ProcessPayload) => Promise }, -'core': { getClusters: () => Promise, +const ARGS_MAP = { 'oneclient':'{"getModpacksFor":["cluster_id"],"getClustersGroupedByMajor":[],"openDevTools":[]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}', 'core':'{"getUsersFromAuthor":["provider","author"],"getUsers":[],"launchCluster":["id","uuid"],"fetchMinecraftProfile":["uuid"],"searchPackages":["provider","query"],"getLoadersForVersion":["mc_version"],"open":["input"],"removeCluster":["id"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getGlobalProfile":[],"getLogByName":["id","name"],"getClusterById":["id"],"getRunningProcesses":[],"getClusters":[],"getDefaultUser":["fallback"],"readSettings":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"createCluster":["options"],"killProcess":["pid"],"getProfileOrDefault":["name"],"getUser":["uuid"],"getPackage":["provider","slug"],"updateClusterById":["id","request"],"writeSettings":["setting"],"setDefaultUser":["uuid"],"getLogs":["id"],"getWorlds":["id"],"openMsaLogin":[],"getRunningProcessesByClusterId":["cluster_id"],"removeUser":["uuid"],"getMultiplePackages":["provider","slugs"],"updateClusterProfile":["name","profile"],"getPackageBody":["provider","body"],"getScreenshots":["id"],"installModpack":["modpack","cluster_id"],"isClusterRunning":["cluster_id"],"getGameVersions":[]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}' } +export type Router = { 'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, createCluster: (options: CreateCluster) => Promise, @@ -270,12 +279,17 @@ getMultiplePackages: (provider: Provider, slugs: string[]) => Promise Promise>, downloadPackage: (provider: Provider, packageId: string, versionId: string, clusterId: number, skipCompatibility: boolean | null) => Promise, getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise, +installModpack: (modpack: ModpackArchive, clusterId: number) => Promise, fetchMinecraftProfile: (uuid: string) => Promise, open: (input: string) => Promise }, -'oneclient': { openDevTools: () => Promise, -getClustersGroupedByMajor: () => Promise> }, +'events': { ingress: (event: IngressPayload) => Promise, +message: (event: MessagePayload) => Promise, +process: (event: ProcessPayload) => Promise }, 'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise } }; +openCluster: (folderName: string) => Promise }, +'oneclient': { openDevTools: () => Promise, +getClustersGroupedByMajor: () => Promise>, +getModpacksFor: (clusterId: number) => Promise } }; export type { InferCommandOutput } diff --git a/apps/oneclient/frontend/src/components/PackageItem.tsx b/apps/oneclient/frontend/src/components/BrowserPackageItem.tsx similarity index 100% rename from apps/oneclient/frontend/src/components/PackageItem.tsx rename to apps/oneclient/frontend/src/components/BrowserPackageItem.tsx diff --git a/apps/oneclient/frontend/src/components/index.ts b/apps/oneclient/frontend/src/components/index.ts index 90841a00..b92bdfd6 100644 --- a/apps/oneclient/frontend/src/components/index.ts +++ b/apps/oneclient/frontend/src/components/index.ts @@ -1,4 +1,5 @@ export * from './AccountAvatar'; +export * from './BrowserPackageItem'; export * from './GameBackground'; export * from './Loader'; export * from './LogViewer'; diff --git a/apps/oneclient/frontend/src/components/overlay/PolyBundleModal.tsx b/apps/oneclient/frontend/src/components/overlay/PolyBundleModal.tsx new file mode 100644 index 00000000..f4d55e1c --- /dev/null +++ b/apps/oneclient/frontend/src/components/overlay/PolyBundleModal.tsx @@ -0,0 +1,14 @@ +import type { OverlayProps } from './Overlay'; +import { Overlay } from './Overlay'; + +export interface PolyBundleModalProps extends OverlayProps { + +} + +export function PolyBundleModal() { + return ( + + test + + ); +} diff --git a/apps/oneclient/frontend/src/routes/app/cluster/browser/index.tsx b/apps/oneclient/frontend/src/routes/app/cluster/browser/index.tsx index fc66e0b5..a2e71494 100644 --- a/apps/oneclient/frontend/src/routes/app/cluster/browser/index.tsx +++ b/apps/oneclient/frontend/src/routes/app/cluster/browser/index.tsx @@ -1,6 +1,5 @@ import type { Filters, PackageCategories, PackageType, Provider, Sort } from '@/bindings.gen'; -import { LoaderSuspense, SheetPage } from '@/components'; -import { PackageGrid } from '@/components/PackageItem'; +import { LoaderSuspense, PackageGrid } from '@/components'; import { bindings } from '@/main'; import { browserCategories, categoryNameFromId } from '@/utils/browser'; import { useCommandSuspense, usePagination } from '@onelauncher/common'; diff --git a/packages/core/src/api/packages/modpack/data.rs b/packages/core/src/api/packages/modpack/data.rs index 8f6567bb..bff15352 100644 --- a/packages/core/src/api/packages/modpack/data.rs +++ b/packages/core/src/api/packages/modpack/data.rs @@ -1,7 +1,18 @@ +use std::path::PathBuf; + use onelauncher_entity::loader::GameLoader; use serde::{Deserialize, Serialize}; use crate::api::packages::data::{ExternalPackage, ManagedPackage, ManagedVersion}; +use crate::api::packages::modpack::ModpackFormat; + +#[onelauncher_macro::specta] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModpackArchive { + pub manifest: ModpackManifest, + pub path: PathBuf, + pub format: ModpackFormat, +} #[onelauncher_macro::specta] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -16,7 +27,14 @@ pub struct ModpackManifest { #[onelauncher_macro::specta] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ModpackFile { +pub struct ModpackFile { + pub enabled: bool, + pub kind: ModpackFileKind, +} + +#[onelauncher_macro::specta] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ModpackFileKind { Managed((ManagedPackage, ManagedVersion)), External(ExternalPackage), } diff --git a/packages/core/src/api/packages/modpack/mod.rs b/packages/core/src/api/packages/modpack/mod.rs index 00b149bf..2e7ceb5d 100644 --- a/packages/core/src/api/packages/modpack/mod.rs +++ b/packages/core/src/api/packages/modpack/mod.rs @@ -4,26 +4,47 @@ use std::sync::Arc; use onelauncher_entity::package::PackageType; use onelauncher_entity::{clusters, packages}; use sea_orm::ActiveValue::Set; +use serde::{Deserialize, Serialize}; use crate::api::packages::PackageError; +use crate::api::packages::modpack::data::ModpackArchive; use crate::api::packages::modpack::mrpack::MrPackFormatImpl; +use crate::api::packages::modpack::polymrpack::PolyMrPackFormatImpl; use crate::api::{self}; use crate::error::LauncherResult; use crate::store::ingress::SubIngress; pub mod data; mod mrpack; +mod polymrpack; #[cfg(test)] mod tests; #[onelauncher_macro::specta] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ModpackFormat { CurseForge, MrPack, + PolyMrPack, } +const FORMAT_PIPELINE_PATH: &[LoaderFn] = &[ + PolyMrPackFormatImpl::from_file, // has to be first because of it being a **VALID** mrpack with extra stuff + MrPackFormatImpl::from_file, +]; + +const FORMAT_PIPELINE_BYTES: &[LoaderFn>>] = &[ + PolyMrPackFormatImpl::from_bytes, // has to be first because of it being a **VALID** mrpack with extra stuff + MrPackFormatImpl::from_bytes, +]; + +type LoaderFn = fn( + Arg, +) -> std::pin::Pin< + Box>>> + Send>, +>; + #[async_trait::async_trait] pub trait ModpackFormatExt { async fn from_file( @@ -37,6 +58,15 @@ pub trait ModpackFormatExt { ) -> LauncherResult>> where Self: Sized; + + async fn install_modpack_archive( + modpack_archive: &ModpackArchive, + cluster: &clusters::Model, + skip_compatibility: Option, + ingress: Option>, + ) -> LauncherResult<()> + where + Self: Sized; } #[async_trait::async_trait] @@ -52,17 +82,9 @@ pub trait InstallableModpackFormatExt: Send + Sync + std::any::Any { fn as_any(self: Box) -> Box; } -type LoaderFn = fn( - Arg, -) -> std::pin::Pin< - Box>>> + Send>, ->; - impl ModpackFormat { - async fn from_file(path: PathBuf) -> LauncherResult> { - let pipeline: Vec> = vec![MrPackFormatImpl::from_file]; - - for stage in pipeline { + pub async fn from_file(path: PathBuf) -> LauncherResult> { + for stage in FORMAT_PIPELINE_PATH { if let Some(format) = stage(path.clone()).await? { return Ok(format); } @@ -71,12 +93,10 @@ impl ModpackFormat { Err(PackageError::UnsupportedModpackFormat.into()) } - async fn from_bytes( + pub async fn from_bytes( bytes: Arc>, ) -> LauncherResult> { - let pipeline: Vec>>> = vec![MrPackFormatImpl::from_bytes]; - - for stage in pipeline { + for stage in FORMAT_PIPELINE_BYTES { if let Some(format) = stage(Arc::clone(&bytes)).await? { return Ok(format); } @@ -84,6 +104,31 @@ impl ModpackFormat { Err(PackageError::UnsupportedModpackFormat.into()) } + + pub async fn install_modpack_archive( + &self, + modpack_archive: &ModpackArchive, + cluster: &clusters::Model, + skip_compatibility: Option, + ingress: Option>, + ) -> LauncherResult<()> { + match self { + ModpackFormat::CurseForge => unimplemented!(), + ModpackFormat::MrPack => MrPackFormatImpl::install_modpack_archive( + modpack_archive, + cluster, + skip_compatibility, + ingress, + ), + ModpackFormat::PolyMrPack => PolyMrPackFormatImpl::install_modpack_archive( + modpack_archive, + cluster, + skip_compatibility, + ingress, + ), + } + .await + } } /// Installs a modpack to a cluster. diff --git a/packages/core/src/api/packages/modpack/mrpack.rs b/packages/core/src/api/packages/modpack/mrpack.rs index 4fdc9856..87df4265 100644 --- a/packages/core/src/api/packages/modpack/mrpack.rs +++ b/packages/core/src/api/packages/modpack/mrpack.rs @@ -10,8 +10,10 @@ use serde::Deserialize; use tokio::sync::OnceCell; use crate::api::cluster::ClusterError; -use crate::api::packages::data::{ExternalPackage, PackageSide}; -use crate::api::packages::modpack::data::{ModpackFile, ModpackManifest}; +use crate::api::packages::data::{ExternalPackage, ManagedVersion, PackageSide}; +use crate::api::packages::modpack::data::{ + ModpackArchive, ModpackFile, ModpackFileKind, ModpackManifest, +}; use crate::api::packages::modpack::{InstallableModpackFormatExt, ModpackFormatExt}; use crate::api::packages::provider::ProviderExt; use crate::api::{self}; @@ -21,17 +23,15 @@ use crate::utils::DatabaseModelExt; use crate::utils::io::{self, IOError}; pub struct MrPackFormatImpl { - archive: Option, - raw_manifest: MrPackManifest, - manifest: OnceCell, - mc_version: String, - loader: GameLoader, - loader_version: String, + pub(super) archive: Option, + pub(super) raw_manifest: MrPackManifest, + pub(super) manifest: OnceCell, + pub(super) mc_version: String, + pub(super) loader: GameLoader, + pub(super) loader_version: String, } -const MODRINTH_URL_PREFIX: &str = "https://cdn.modrinth.com/"; - -// TODO: move from_file fetching to manifest function +pub(super) const MODRINTH_URL_PREFIX: &str = "https://cdn.modrinth.com/"; #[async_trait::async_trait] impl ModpackFormatExt for MrPackFormatImpl { @@ -112,6 +112,23 @@ impl ModpackFormatExt for MrPackFormatImpl { loader_version: loader_version.unwrap(), }))) } + + async fn install_modpack_archive( + modpack_archive: &ModpackArchive, + cluster: &clusters::Model, + skip_compatibility: Option, + ingress: Option>, + ) -> LauncherResult<()> + where + Self: Sized, + { + let ModpackArchive { manifest, path, .. } = modpack_archive; + + download_and_link_packages(cluster, &manifest, skip_compatibility, &ingress).await?; + copy_overrides_folder(cluster, &path, &ingress).await?; + + Ok(()) + } } #[async_trait::async_trait] @@ -128,79 +145,7 @@ impl InstallableModpackFormatExt for MrPackFormatImpl { return Ok(manifest); } - let mut project_ids_to_fetch: Vec = Vec::new(); - let mut version_ids_to_fetch: Vec = Vec::new(); - let mut files: Vec = Vec::new(); - - for file in &self.raw_manifest.files { - let name = file - .path - .split('/') - .last() - .unwrap_or(&file.path) - .to_string(); - - if let Some(url) = file - .downloads - .iter() - .find(|url| url.starts_with(MODRINTH_URL_PREFIX)) - { - // https://cdn.modrinth.com/data//versions// - let paths = url[MODRINTH_URL_PREFIX.len()..] - .split('/') - .collect::>(); - - if paths.len() >= 4 { - let project_id = paths[1]; - let version_id = paths[3]; - - project_ids_to_fetch.push(project_id.to_string()); - version_ids_to_fetch.push(version_id.to_string()) - } else { - tracing::error!("invalid modrinth file URL: '{}'", url); - } - } else { - let download_url = file - .downloads - .first() - .cloned() - .ok_or_else(|| { - tracing::warn!("mrpack file '{}' does not contain a download URL", name) - }) - .unwrap_or(String::new()); - - // the path usually contains the folder name such as "mods" or "resourcepacks" - // so we can use it to determine the package type - let package_type = file - .path - .split('/') - .next() - .and_then(|s| PackageType::try_from(s).ok()) - .unwrap_or(PackageType::Mod); - - files.push(ModpackFile::External(ExternalPackage { - name, - url: download_url, - sha1: file.hashes.sha1.clone(), - size: file.file_size, - package_type, - })); - } - } - - let managed_packages = Provider::Modrinth - .get_multiple(&project_ids_to_fetch) - .await?; - let managed_versions = Provider::Modrinth - .get_versions(&version_ids_to_fetch) - .await?; - - files.extend( - managed_packages - .into_iter() - .zip(managed_versions.into_iter()) - .map(|(package, version)| ModpackFile::Managed((package, version))), - ); + let files = to_modpack_files(&self.raw_manifest.files).await?; let manifest = ModpackManifest { name: self.raw_manifest.name.clone(), @@ -225,91 +170,217 @@ impl InstallableModpackFormatExt for MrPackFormatImpl { ) -> LauncherResult<()> { let manifest = self.manifest().await?; - if cluster.mc_version != manifest.mc_version { - return Err(ClusterError::MismatchedVersion( - manifest.mc_version.clone(), - cluster.mc_version.clone(), - ) - .into()); - } + download_and_link_packages(cluster, &manifest, skip_compatibility, &ingress).await?; - if cluster.mc_loader != manifest.loader { - return Err(ClusterError::MismatchedLoader( - manifest.loader.clone(), - cluster.mc_loader.clone(), - ) - .into()); - } - - // TODO: Implement loader version checking - // if cluster.mc_loader_version.is_some_and(|v| v ) - - let mut errors = Vec::new(); - let mut packages_to_link = Vec::new(); - - // TODO: Ingress - for file in manifest.files.iter() { - match file { - ModpackFile::Managed((package, version)) => { - match api::packages::download_package(package, version, None, None).await { - Ok(model) => packages_to_link.push(model), - Err(e) => errors.push(e), - } - } - ModpackFile::External(package) => { - if let Err(e) = api::packages::download_external_package( - package, - cluster, - None, - skip_compatibility, - None, - ) - .await - { - errors.push(e); - } - } - } - } - - let linked = api::packages::link_many_packages_to_cluster( - &packages_to_link, - cluster, - skip_compatibility, - ) - .await?; - if linked < packages_to_link.len() as u64 { - tracing::warn!("not all packages could be linked to the cluster, some errors occurred"); - } - - if let Some(archive) = &self.archive { - tracing::debug!( - "extracting overrides from modpack archive: {}", - archive.display() - ); - let dest = cluster.path().await?; - - io::extract_zip_filtered( - archive, - dest, - Some(|entry: &async_zip::StoredZipEntry| { - entry - .filename() - .as_str() - .is_ok_and(|s| s.starts_with("overrides/")) - }), - Some(|name: &str| name.trim_start_matches("overrides/").to_string()), - ) - .await?; + if let Some(path) = self.archive.as_ref() { + copy_overrides_folder(cluster, path, &ingress).await?; } Ok(()) } } +pub(super) async fn download_and_link_packages( + cluster: &clusters::Model, + manifest: &ModpackManifest, + skip_compatibility: Option, + _ingress: &Option>, +) -> LauncherResult<()> { + if cluster.mc_version != manifest.mc_version { + return Err(ClusterError::MismatchedVersion( + manifest.mc_version.clone(), + cluster.mc_version.clone(), + ) + .into()); + } + + if cluster.mc_loader != manifest.loader { + return Err(ClusterError::MismatchedLoader( + manifest.loader.clone(), + cluster.mc_loader.clone(), + ) + .into()); + } + + // TODO: Implement loader version checking + // if cluster.mc_loader_version.is_some_and(|v| v ) + + let mut errors = Vec::new(); + let mut packages_to_link = Vec::new(); + + // TODO: Ingress + for file in manifest.files.iter() { + match &file.kind { + ModpackFileKind::Managed((package, version)) => { + match api::packages::download_package(package, version, None, None).await { + Ok(model) => packages_to_link.push(model), + Err(e) => errors.push(e), + } + } + ModpackFileKind::External(package) => { + if let Err(e) = api::packages::download_external_package( + package, + cluster, + None, + skip_compatibility, + None, + ) + .await + { + errors.push(e); + } + } + } + } + + let linked = api::packages::link_many_packages_to_cluster( + &packages_to_link, + cluster, + skip_compatibility, + ) + .await?; + if linked < packages_to_link.len() as u64 { + tracing::warn!("not all packages could be linked to the cluster, some errors occurred"); + } + Ok(()) +} + +pub(super) async fn copy_overrides_folder( + cluster: &clusters::Model, + archive_path: &PathBuf, + _ingress: &Option>, +) -> LauncherResult<()> { + tracing::debug!( + "extracting overrides from modpack archive: {}", + archive_path.display() + ); + let dest = cluster.path().await?; + + io::extract_zip_filtered( + archive_path, + dest, + Some(|entry: &async_zip::StoredZipEntry| { + entry + .filename() + .as_str() + .is_ok_and(|s| s.starts_with("overrides/")) + }), + Some(|name: &str| name.trim_start_matches("overrides/").to_string()), + ) + .await?; + + Ok(()) +} + +async fn to_modpack_files(mrpack_files: &Vec) -> LauncherResult> { + struct ToFetch { + project_id: String, + version_id: String, + } + + let mut to_fetch: Vec = Vec::new(); + let mut files: Vec = Vec::new(); + + for file in mrpack_files { + let name = file + .path + .split('/') + .last() + .unwrap_or(&file.path) + .to_string(); + + if let Some(url) = file + .downloads + .iter() + .find(|url| url.starts_with(MODRINTH_URL_PREFIX)) + { + // https://cdn.modrinth.com/data//versions// + let paths = url[MODRINTH_URL_PREFIX.len()..] + .split('/') + .collect::>(); + + if paths.len() >= 4 { + let project_id = paths[1]; + let version_id = paths[3]; + + to_fetch.push(ToFetch { + project_id: project_id.to_string(), + version_id: version_id.to_string(), + }); + } else { + tracing::error!("invalid modrinth file URL: '{}'", url); + } + } else { + let download_url = file + .downloads + .first() + .cloned() + .ok_or_else(|| { + tracing::warn!("mrpack file '{}' does not contain a download URL", name) + }) + .unwrap_or(String::new()); + + // the path usually contains the folder name such as "mods" or "resourcepacks" + // so we can use it to determine the package type + let package_type = file + .path + .split('/') + .next() + .and_then(|s| PackageType::try_from(s).ok()) + .unwrap_or(PackageType::Mod); + + files.push(ModpackFile { + kind: ModpackFileKind::External(ExternalPackage { + name, + url: download_url, + sha1: file.hashes.sha1.clone(), + size: file.file_size, + package_type, + }), + enabled: true, + }); + } + } + + let managed_packages = Provider::Modrinth + .get_multiple( + &to_fetch + .iter() + .map(|f| f.project_id.clone()) + .collect::>(), + ) + .await?; + let managed_versions = Provider::Modrinth + .get_versions( + &to_fetch + .iter() + .map(|f| f.version_id.clone()) + .collect::>(), + ) + .await?; + + let mut version_map = managed_versions + .into_iter() + .map(|v| (v.project_id.clone(), v)) + .collect::>(); + + for fetched_pkg in managed_packages { + if let Some(version) = version_map.remove(&fetched_pkg.id) { + files.push(ModpackFile { + kind: ModpackFileKind::Managed((fetched_pkg, version)), + enabled: true, + }); + } else { + tracing::error!("no version found for managed package '{}'", fetched_pkg.id); + } + } + + Ok(files) +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] -struct MrPackManifest { +pub(super) struct MrPackManifest { pub format_version: usize, pub version_id: String, pub name: String, @@ -319,7 +390,7 @@ struct MrPackManifest { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] -struct MrPackFile { +pub(super) struct MrPackFile { pub path: String, pub hashes: MrPackFileHash, #[serde(default)] @@ -330,14 +401,14 @@ struct MrPackFile { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] -struct MrPackFileHash { +pub(super) struct MrPackFileHash { pub sha1: String, // pub sha512: String, } #[derive(Default, Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] -struct MrPackFileEnv { +pub(super) struct MrPackFileEnv { pub client: PackageSide, pub server: PackageSide, } diff --git a/packages/core/src/api/packages/modpack/polymrpack.rs b/packages/core/src/api/packages/modpack/polymrpack.rs new file mode 100644 index 00000000..79c6e4c0 --- /dev/null +++ b/packages/core/src/api/packages/modpack/polymrpack.rs @@ -0,0 +1,322 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::Arc; + +use onelauncher_entity::clusters; +use onelauncher_entity::loader::GameLoader; +use onelauncher_entity::package::{PackageType, Provider}; +use serde::Deserialize; +use tokio::sync::OnceCell; + +use crate::api::packages::data::{ExternalPackage, ManagedVersion}; +use crate::api::packages::modpack::data::{ + ModpackArchive, ModpackFile, ModpackFileKind, ModpackManifest, +}; +use crate::api::packages::modpack::mrpack::MrPackFile; +use crate::api::packages::modpack::{InstallableModpackFormatExt, ModpackFormatExt}; +use crate::api::packages::provider::ProviderExt; +use crate::error::LauncherResult; +use crate::store::ingress::SubIngress; +use crate::utils::io::{self, IOError}; + +pub struct PolyMrPackFormatImpl { + pub(super) archive: Option, + pub(super) raw_manifest: PolyMrPackManifest, + pub(super) manifest: OnceCell, + pub(super) mc_version: String, + pub(super) loader: GameLoader, + pub(super) loader_version: String, +} + +#[async_trait::async_trait] +impl ModpackFormatExt for PolyMrPackFormatImpl { + async fn from_file( + path: std::path::PathBuf, + ) -> LauncherResult>> + where + Self: Sized, + { + let zip_file = tokio::fs::File::open(path.clone()) + .await + .map_err(IOError::from)?; + let buf_reader = tokio::io::BufReader::new(zip_file); + + let manifest_file = io::try_read_zip_entry_bytes(buf_reader, "modrinth.index.json").await?; + + let Some(this) = Self::from_bytes(Arc::new(manifest_file)).await? else { + return Ok(None); + }; + + let mut this = this + .as_any() + .downcast::() + .expect("downcast failed for MrPackFormatImpl"); + + this.archive = Some(path); + Ok(Some(this)) + } + + async fn from_bytes( + bytes: Arc>, + ) -> LauncherResult>> + where + Self: Sized, + { + let serialized: PolyMrPackManifest = match serde_json::from_slice(&bytes) { + Ok(manifest) => manifest, + Err(e) => { + tracing::debug!("failed to deserialize modpack as polymrpack: {}", e); + return Ok(None); + } + }; + + let mut mc_version: Option = None; + let mut loader: Option = None; + let mut loader_version: Option = None; + + for (key, value) in serialized.dependencies.iter() { + if key == "minecraft" { + mc_version = Some(value.clone()); + } else { + loader = GameLoader::from_str(key).ok(); + loader_version = Some(value.clone()); + } + } + + if mc_version.is_none() { + tracing::error!("polymrpack manifest does not contain a minecraft version"); + return Ok(None); + } + + if loader.is_none() { + tracing::error!("polymrpack manifest does not contain a valid game loader"); + return Ok(None); + } + + if loader_version.is_none() { + tracing::error!("polymrpack manifest does not contain a valid loader version"); + return Ok(None); + } + + Ok(Some(Box::new(Self { + archive: None, + raw_manifest: serialized, + manifest: OnceCell::new(), + mc_version: mc_version.unwrap(), + loader: loader.unwrap(), + loader_version: loader_version.unwrap(), + }))) + } + + async fn install_modpack_archive( + modpack_archive: &ModpackArchive, + cluster: &clusters::Model, + skip_compatibility: Option, + ingress: Option>, + ) -> LauncherResult<()> + where + Self: Sized, + { + let ModpackArchive { manifest, path, .. } = modpack_archive; + + super::mrpack::download_and_link_packages(cluster, &manifest, skip_compatibility, &ingress) + .await?; + super::mrpack::copy_overrides_folder(cluster, &path, &ingress).await?; + + Ok(()) + } +} + +#[async_trait::async_trait] +impl InstallableModpackFormatExt for PolyMrPackFormatImpl { + fn as_any(self: Box) -> Box + where + Self: Sized, + { + self + } + + async fn manifest(&self) -> LauncherResult<&ModpackManifest> { + if let Some(manifest) = self.manifest.get() { + return Ok(manifest); + } + + let files = to_modpack_files(&self.raw_manifest.files).await?; + + let manifest = ModpackManifest { + name: self.raw_manifest.name.clone(), + version: self.raw_manifest.version_id.clone(), + mc_version: self.mc_version.clone(), + loader: self.loader.clone(), + loader_version: self.loader_version.clone(), + files, + }; + + self.manifest + .set(manifest) + .expect("failed to cache inner modpack manifest"); + Ok(self.manifest.get().unwrap()) + } + + async fn install_to( + &self, + cluster: &clusters::Model, + skip_compatibility: Option, + ingress: Option>, + ) -> LauncherResult<()> { + let manifest = self.manifest().await?; + + super::mrpack::download_and_link_packages(cluster, &manifest, skip_compatibility, &ingress) + .await?; + + if let Some(path) = self.archive.as_ref() { + super::mrpack::copy_overrides_folder(cluster, path, &ingress).await?; + } + + Ok(()) + } +} + +async fn to_modpack_files(mrpack_files: &Vec) -> LauncherResult> { + struct FetchedPackage { + version_id: String, + enabled: bool, + } + + let mut to_fetch: HashMap = HashMap::new(); + let mut files: Vec = Vec::new(); + + for file in mrpack_files { + if file.hidden { + continue; + } + + let name = file + .base + .path + .split('/') + .last() + .unwrap_or(&file.base.path) + .to_string(); + + if let Some(url) = file + .base + .downloads + .iter() + .find(|url| url.starts_with(super::mrpack::MODRINTH_URL_PREFIX)) + { + // https://cdn.modrinth.com/data//versions// + let paths = url[super::mrpack::MODRINTH_URL_PREFIX.len()..] + .split('/') + .collect::>(); + + if paths.len() >= 4 { + let project_id = paths[1]; + let version_id = paths[3]; + + to_fetch.insert( + project_id.to_string(), + FetchedPackage { + version_id: version_id.to_string(), + enabled: file.enabled, + }, + ); + } else { + tracing::error!("invalid modrinth file URL: '{}'", url); + } + } else { + let download_url = file + .base + .downloads + .first() + .cloned() + .ok_or_else(|| { + tracing::warn!("mrpack file '{}' does not contain a download URL", name) + }) + .unwrap_or(String::new()); + + // the path usually contains the folder name such as "mods" or "resourcepacks" + // so we can use it to determine the package type + let package_type = file + .base + .path + .split('/') + .next() + .and_then(|s| PackageType::try_from(s).ok()) + .unwrap_or(PackageType::Mod); + + files.push(ModpackFile { + enabled: file.enabled, + kind: ModpackFileKind::External(ExternalPackage { + name, + url: download_url, + sha1: file.base.hashes.sha1.clone(), + size: file.base.file_size, + package_type, + }), + }); + } + } + + let managed_packages = Provider::Modrinth + .get_multiple(&to_fetch.keys().cloned().collect::>()) + .await?; + let managed_versions = Provider::Modrinth + .get_versions( + &to_fetch + .values() + .map(|f| f.version_id.clone()) + .collect::>(), + ) + .await?; + + let mut version_map = managed_versions + .into_iter() + .map(|v| (v.project_id.clone(), v)) + .collect::>(); + + for fetched_pkg in managed_packages { + if let Some(version) = version_map.remove(&fetched_pkg.id) { + let enabled = to_fetch + .get(&fetched_pkg.id) + .map(|f| f.enabled) + .unwrap_or(true); + + files.push(ModpackFile { + enabled, + kind: ModpackFileKind::Managed((fetched_pkg, version)), + }); + } else { + tracing::error!("no version found for managed package '{}'", fetched_pkg.id); + } + } + + Ok(files) +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct PolyMrPackManifest { + pub id: String, + pub category: String, + pub enabled: bool, + #[serde(rename = "polyFormat")] + pub poly_format_version: i32, + pub update_url: Option, + + pub format_version: usize, + pub version_id: String, + pub name: String, + pub files: Vec, + pub dependencies: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct PolyMrPackFile { + #[serde(flatten)] + pub base: MrPackFile, + pub enabled: bool, + pub hidden: bool, +} diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index 7cfcf3f4..560057f5 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -2,6 +2,7 @@ use crate::api::packages::data::{ ManagedPackage, ManagedPackageBody, ManagedUser, ManagedVersion, PackageAuthor, SearchQuery, SearchResult, }; +use crate::api::packages::modpack::data::ModpackArchive; use crate::api::packages::provider::ProviderExt; use crate::store::processes::Process; use crate::store::{Settings, State}; @@ -26,6 +27,7 @@ use onelauncher_entity::prelude::model::*; #[taurpc::procedures(path = "core")] pub trait TauriLauncherApi { // MARK: API: clusters + // region: clusters #[taurpc(alias = "getClusters")] async fn get_clusters() -> LauncherResult>; @@ -55,8 +57,10 @@ pub trait TauriLauncherApi { #[taurpc(alias = "getLogByName")] async fn get_log_by_name(id: ClusterId, name: String) -> LauncherResult>; + // endregion: clusters // MARK: API: processes + // region: processes #[taurpc(alias = "getRunningProcesses")] async fn get_running_processes() -> LauncherResult>; @@ -70,8 +74,10 @@ pub trait TauriLauncherApi { #[taurpc(alias = "killProcess")] async fn kill_process(pid: u32) -> LauncherResult<()>; + // endregion: processes // MARK: API: profiles + // region: profiles #[taurpc(alias = "getProfileOrDefault")] async fn get_profile_or_default(name: Option) -> LauncherResult; @@ -83,15 +89,19 @@ pub trait TauriLauncherApi { name: String, profile: ProfileUpdate, ) -> LauncherResult; + // endregion: profiles // MARK: API: metadata + // region: metadata #[taurpc(alias = "getGameVersions")] async fn get_game_versions() -> LauncherResult>; #[taurpc(alias = "getLoadersForVersion")] async fn get_loaders_for_version(mc_version: String) -> LauncherResult>; + // endregion: metadata // MARK: API: users + // region: users #[taurpc(alias = "getUsers")] async fn get_users() -> LauncherResult>; @@ -113,15 +123,19 @@ pub trait TauriLauncherApi { async fn open_msa_login( app_handle: AppHandle, ) -> LauncherResult>; + // endregion: users // MARK: API: settings + // region: settings #[taurpc(alias = "readSettings")] async fn read_settings() -> LauncherResult; #[taurpc(alias = "writeSettings")] async fn write_settings(setting: Settings) -> LauncherResult<()>; + // endregion: settings - // MARK: API: packages + // MARK: API: package + // region: package #[taurpc(alias = "searchPackages")] async fn search_packages( provider: Provider, @@ -170,6 +184,13 @@ pub trait TauriLauncherApi { provider: Provider, author: PackageAuthor, ) -> LauncherResult>; + // endregion: package + + // MARK: API: modpack + // region: modpack + #[taurpc(alias = "installModpack")] + async fn install_modpack(modpack: ModpackArchive, cluster_id: ClusterId) -> LauncherResult<()>; + // endregion: modpack // MARK: API: minecraft #[taurpc(alias = "fetchMinecraftProfile")] @@ -214,6 +235,7 @@ pub struct TauriLauncherApiImpl; #[taurpc::resolvers] impl TauriLauncherApi for TauriLauncherApiImpl { // MARK: Impl: clusters + // region: clusters async fn get_clusters(self) -> LauncherResult> { api::cluster::dao::get_all_clusters().await } @@ -340,8 +362,10 @@ impl TauriLauncherApi for TauriLauncherApiImpl { api::cluster::content::get_log_by_name(&cluster, &name).await } + // endregion: clusters // MARK: Impl: processes + // region: processes async fn get_running_processes(self) -> LauncherResult> { api::processes::get_running_processes().await } @@ -360,8 +384,10 @@ impl TauriLauncherApi for TauriLauncherApiImpl { async fn kill_process(self, pid: u32) -> LauncherResult<()> { api::processes::kill_process(pid).await } + // endregion: processes // MARK: Impl: profiles + // region: profiles async fn get_global_profile(self) -> SettingsProfile { api::setting_profiles::get_global_profile().await } @@ -418,8 +444,10 @@ impl TauriLauncherApi for TauriLauncherApiImpl { Ok(profile) } + // endregion: profiles // MARK: Impl: metadata + // region: metadata async fn get_loaders_for_version(self, mc_version: String) -> LauncherResult> { api::game::metadata::get_loaders_for_version(&mc_version).await } @@ -427,8 +455,10 @@ impl TauriLauncherApi for TauriLauncherApiImpl { async fn get_game_versions(self) -> LauncherResult> { api::game::metadata::get_game_versions().await } + // endregion: metadata // MARK: Impl: users + // region: users async fn get_users(self) -> LauncherResult> { api::credentials::get_users().await } @@ -516,7 +546,17 @@ impl TauriLauncherApi for TauriLauncherApiImpl { Ok(None) } + async fn get_users_from_author( + self, + provider: Provider, + author: PackageAuthor, + ) -> LauncherResult> { + provider.get_users_from_author(author).await + } + // endregion: users + // MARK: Impl: settings + // region: settings async fn read_settings(self) -> LauncherResult { let state = State::get().await?; let settings = state.settings.read().await; @@ -534,8 +574,10 @@ impl TauriLauncherApi for TauriLauncherApiImpl { Ok(()) } + // endregion: settings - // MARK: Impl: packages + // MARK: Impl: package + // region: package async fn search_packages( self, provider: Provider, @@ -604,26 +646,41 @@ impl TauriLauncherApi for TauriLauncherApiImpl { Ok(model) } + // endregion: package - async fn get_users_from_author( + // MARK: Impl: modpack + // region: modpack + async fn install_modpack( self, - provider: Provider, - author: PackageAuthor, - ) -> LauncherResult> { - provider.get_users_from_author(author).await + modpack: ModpackArchive, + cluster_id: ClusterId, + ) -> LauncherResult<()> { + let cluster = api::cluster::dao::get_cluster_by_id(cluster_id) + .await? + .ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", cluster_id))?; + + modpack + .format + .install_modpack_archive(&modpack, &cluster, None, None) + .await } + // endregion: modpack // MARK: Impl: minecraft + // region: minecraft async fn fetch_player_profile( self, uuid: String, ) -> LauncherResult { crate::utils::minecraft::fetch_player_profile(&uuid).await } + // endregion: minecraft // MARK: Impl: Other + // region: other async fn open(self, input: String) -> LauncherResult<()> { opener::open(input)?; Ok(()) } + // endregion: other } diff --git a/packages/core/src/utils/http.rs b/packages/core/src/utils/http.rs index dea14d51..b68d12f6 100644 --- a/packages/core/src/utils/http.rs +++ b/packages/core/src/utils/http.rs @@ -11,7 +11,7 @@ use tokio::sync::Semaphore; use tokio_stream::StreamExt; use crate::api::ingress::IngressSendExt; -use crate::error::LauncherResult; +use crate::error::{LauncherError, LauncherResult}; use crate::store::Core; use crate::store::ingress::{SubIngress, SubIngressExt}; use crate::store::semaphore::SemaphoreStore; @@ -49,6 +49,48 @@ pub(crate) static REQWEST_CLIENT: LazyLock = LazyLock::new(|| { .expect("failed to build reqwest client!") }); +pub async fn request( + method: Method, + url: &str, + builder: Option reqwest::Result>, +) -> LauncherResult { + let semaphore = SemaphoreStore::fetch().await; + let permit = semaphore.acquire().await; + + let client = &REQWEST_CLIENT; + + let mut attempt = 0; + let res = loop { + attempt += 1; + + let req = client.request(method.clone(), url); + let req = match builder { + Some(ref builder) => builder(req), + None => req.build(), + } + .map_err(LauncherError::from)?; + + match client.execute(req).await { + Err(err) => { + if attempt <= Core::get().fetch_attempts { + tracing::error!( + "error occurred whilst fetching on attempt {attempt}: {err} retrying request..." + ); + continue; + } + + return Err(err.into()); + } + Ok(res) => break res, + } + }; + + // Drop the fetch permit as we are no longer fetching + drop(permit); + + Ok(res) +} + #[tracing::instrument(level = "debug", skip(ingress))] #[onelauncher_macro::pin] pub async fn fetch_advanced( diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 0ae0d582..a7e35fb2 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "nightly-2025-07-23" +channel = "nightly-2025-09-12" profile = "minimal" components = [ "cargo",