From 1eb6505c0ccf62357bf92664bb1011e810004aae Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 21 Sep 2025 17:08:03 +0200 Subject: [PATCH] feat: oneclient bundles --- apps/oneclient/desktop/src/api/commands.rs | 40 +++--- apps/oneclient/desktop/src/constants.rs | 2 +- .../desktop/src/oneclient/bundles.rs | 121 +++++++++++++++--- apps/oneclient/desktop/src/oneclient/mod.rs | 4 +- apps/oneclient/frontend/src/bindings.gen.ts | 14 +- .../src/routes/app/cluster/overview.tsx | 9 +- packages/core/src/api/packages/modpack/mod.rs | 4 +- .../core/src/api/packages/modpack/mrpack.rs | 8 ++ .../src/api/packages/modpack/polymrpack.rs | 8 +- .../core/src/api/packages/modpack/tests.rs | 2 +- packages/core/src/utils/http.rs | 11 +- packages/core/src/utils/io.rs | 5 + 12 files changed, 167 insertions(+), 61 deletions(-) diff --git a/apps/oneclient/desktop/src/api/commands.rs b/apps/oneclient/desktop/src/api/commands.rs index 6eebf438..455dbed0 100644 --- a/apps/oneclient/desktop/src/api/commands.rs +++ b/apps/oneclient/desktop/src/api/commands.rs @@ -3,10 +3,11 @@ 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; +use crate::oneclient::bundles::BundlesManager; + #[taurpc::procedures(path = "oneclient", export_to = "../frontend/src/bindings.gen.ts")] pub trait OneClientApi { #[taurpc(alias = "openDevTools")] @@ -15,8 +16,8 @@ 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(alias = "getBundlesFor")] + async fn get_bundles_for(cluster_id: ClusterId) -> LauncherResult>; } #[taurpc::ipc_type] @@ -54,26 +55,21 @@ 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, - } + async fn get_bundles_for(self, cluster_id: ClusterId) -> LauncherResult> { + let cluster = onelauncher_core::api::cluster::dao::get_cluster_by_id(cluster_id) + .await? + .ok_or_else(|| { + onelauncher_core::error::LauncherError::from(anyhow::anyhow!( + "cluster with id {} not found", + cluster_id + )) + })?; - // 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, - // }, - // ]; + let bundles = BundlesManager::get() + .await + .get_bundles_for(&cluster.mc_version, cluster.mc_loader) + .await?; - Ok(vec![]) + Ok(bundles) } } diff --git a/apps/oneclient/desktop/src/constants.rs b/apps/oneclient/desktop/src/constants.rs index 86147321..8a278c20 100644 --- a/apps/oneclient/desktop/src/constants.rs +++ b/apps/oneclient/desktop/src/constants.rs @@ -6,4 +6,4 @@ 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"; + "https://raw.githubusercontent.com/PolyFrost/DataStorage/refs/heads/main/oneclient"; diff --git a/apps/oneclient/desktop/src/oneclient/bundles.rs b/apps/oneclient/desktop/src/oneclient/bundles.rs index f94cea1b..f5d32859 100644 --- a/apps/oneclient/desktop/src/oneclient/bundles.rs +++ b/apps/oneclient/desktop/src/oneclient/bundles.rs @@ -1,12 +1,15 @@ use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; use onelauncher_core::api::packages::modpack::data::ModpackArchive; +use onelauncher_core::api::packages::modpack::{InstallableModpackFormatExt, ModpackFormat}; 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 reqwest::{Method, header}; use tokio::sync::{OnceCell, RwLock}; /// e.g. @@ -54,7 +57,7 @@ impl BundlesManager { loader: onelauncher_core::entity::loader::GameLoader, ) -> LauncherResult> { let manifest = self.manifest.read().await; - let mut bundles_lock = self.bundles.write().await; + let bundles_lock = self.bundles.read().await; if let Some(entry) = bundles_lock.get(mc_version) { if let Some(bundles) = entry.get(&loader) { @@ -62,28 +65,60 @@ impl BundlesManager { } } + // drop read lock as we're gonna acquire a write lock this time + drop(bundles_lock); + + let mut bundles_lock = self.bundles.write().await; + let mut found = Vec::new(); - // for (version, loaders) in &manifest.versions { - // if version != mc_version { - // continue; - // } + for (version, loaders) in &manifest.versions { + if version != mc_version { + continue; + } - // let Some(paths) = loaders.get(&loader.get_format_name()) else { - // 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?; + // we will be first checking the disk cache, if that fails we fetch from remote + for path in paths { + let Some(file_name) = path.split('/').last() else { + tracing::error!("no bundle name was found in path: {path}"); + continue; + }; - // // res. + let disk_path = BundlesManager::dir().await.join("bundles").join(file_name); - // let name = path.split('/').last().unwrap_or(&format!("bundle-{}-{}-{}.mrpack")); - // } + let modpack = match download_and_load_bundle(path, &disk_path).await { + Ok(modpack) => modpack, + Err(e) => { + tracing::error!("failed to load bundle from {path}: {e}"); + continue; + } + }; - // } + let manifest = match modpack.manifest().await { + Ok(manifest) => manifest, + Err(e) => { + tracing::error!("failed to load modpack manifest from {path}: {e}"); + continue; + } + } + .clone(); + + found.push(ModpackArchive { + manifest, + path: disk_path, + format: modpack.kind(), + }); + } + } + + bundles_lock + .entry(mc_version.to_string()) + .or_default() + .insert(loader, found.clone()); Ok(found.clone()) } @@ -96,6 +131,12 @@ impl BundlesManager { match http::fetch_json::(Method::GET, &url, None, None).await { Ok(manifest) => { + io::create_dir_all(manifest_path.parent().unwrap()) + .await + .unwrap_or_else(|e| { + tracing::error!("failed to create bundles dir: {e}"); + }); + if let Err(e) = io::write_json(&manifest_path, &manifest).await { send_error!("failed to cache bundles manifest to disk: {e}"); } @@ -133,3 +174,49 @@ impl BundlesManager { .join("bundles") } } + +#[tracing::instrument] +async fn download_and_load_bundle( + url_path: &str, + disk_path: &PathBuf, +) -> LauncherResult> { + let url = format!("{}{}", crate::constants::META_URL_BASE, url_path); + + if disk_path.exists() { + // we check if the remote file is different to the local file + let res = http::request(Method::HEAD, &url).await?; + + if !res.status().is_success() { + return Err(anyhow::anyhow!("failed to download bundle from remote: {}", url).into()); + } + + if res.headers().get(reqwest::header::CONTENT_LENGTH).is_none() { + return Err(anyhow::anyhow!( + "bundle at {url} missing content-length header, skipping..." + ) + .into()); + } + + // TODO: check hash if provided in future, for now we check file size :( + let content_length = res + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + let file_size = io::stat(disk_path).await.map(|m| m.len()).unwrap_or(0); + + tracing::debug!("bundle content length: {content_length}, local file size: {file_size}"); + if content_length == file_size { + // file is up to date, load from disk + return Ok(ModpackFormat::from_file(disk_path).await?); + } + } + + tracing::debug!("downloading bundle from remote: {url}"); + // if we are at this point, it means we either need to update or download + let bytes = http::download(Method::GET, &url, disk_path, None, None).await?; + + Ok(ModpackFormat::from_bytes(Arc::new(bytes.to_vec())).await?) +} diff --git a/apps/oneclient/desktop/src/oneclient/mod.rs b/apps/oneclient/desktop/src/oneclient/mod.rs index fba8e420..5e5558ff 100644 --- a/apps/oneclient/desktop/src/oneclient/mod.rs +++ b/apps/oneclient/desktop/src/oneclient/mod.rs @@ -2,5 +2,7 @@ pub mod bundles; pub mod clusters; pub async fn initialize_oneclient() { - clusters::init_clusters().await; + if let Err(err) = clusters::init_clusters().await { + tracing::error!("failed to initialize clusters: {err}"); + } } diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index 565c6244..d9c742d8 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -244,7 +244,7 @@ export type VersionType = */ "old_beta" -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"]}' } +const ARGS_MAP = { 'core':'{"open":["input"],"setDefaultUser":["uuid"],"getScreenshots":["id"],"getClusters":[],"getClusterById":["id"],"getDefaultUser":["fallback"],"removeCluster":["id"],"getProfileOrDefault":["name"],"getRunningProcessesByClusterId":["cluster_id"],"getWorlds":["id"],"fetchMinecraftProfile":["uuid"],"getUser":["uuid"],"getLogByName":["id","name"],"writeSettings":["setting"],"installModpack":["modpack","cluster_id"],"killProcess":["pid"],"updateClusterProfile":["name","profile"],"getUsersFromAuthor":["provider","author"],"getUsers":[],"readSettings":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getPackage":["provider","slug"],"updateClusterById":["id","request"],"getLogs":["id"],"getGameVersions":[],"getGlobalProfile":[],"searchPackages":["provider","query"],"getRunningProcesses":[],"isClusterRunning":["cluster_id"],"getMultiplePackages":["provider","slugs"],"getLoadersForVersion":["mc_version"],"openMsaLogin":[],"removeUser":["uuid"],"createCluster":["options"],"getPackageBody":["provider","body"],"launchCluster":["id","uuid"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"]}', 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[],"getBundlesFor":["cluster_id"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"ingress":["event"],"process":["event"],"message":["event"]}' } export type Router = { 'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, @@ -282,14 +282,14 @@ getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise Promise, fetchMinecraftProfile: (uuid: string) => Promise, open: (input: string) => Promise }, -'events': { ingress: (event: IngressPayload) => Promise, -message: (event: MessagePayload) => Promise, -process: (event: ProcessPayload) => Promise }, -'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise }, 'oneclient': { openDevTools: () => Promise, getClustersGroupedByMajor: () => Promise>, -getModpacksFor: (clusterId: number) => Promise } }; +getBundlesFor: (clusterId: number) => Promise }, +'folders': { fromCluster: (folderName: string) => Promise, +openCluster: (folderName: string) => Promise }, +'events': { ingress: (event: IngressPayload) => Promise, +message: (event: MessagePayload) => Promise, +process: (event: ProcessPayload) => Promise } }; export type { InferCommandOutput } diff --git a/apps/oneclient/frontend/src/routes/app/cluster/overview.tsx b/apps/oneclient/frontend/src/routes/app/cluster/overview.tsx index be38ff7a..8c9c8192 100644 --- a/apps/oneclient/frontend/src/routes/app/cluster/overview.tsx +++ b/apps/oneclient/frontend/src/routes/app/cluster/overview.tsx @@ -1,15 +1,8 @@ import { SheetPage } from '@/components/SheetPage'; -import { bindings } from '@/main'; import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/app/cluster/overview')({ component: RouteComponent, - async beforeLoad(ctx) { - const cluster = await bindings.core.getClusterById(ctx.search.clusterId); - return { - cluster, - }; - }, }); function RouteComponent() { @@ -21,7 +14,7 @@ function RouteComponent() {

Cluster Overview

Welcome to the cluster overview page. Here you can find information about your clusters.

- + Cluster Name: {cluster.name}

test

{/* Additional content can be added here */} diff --git a/packages/core/src/api/packages/modpack/mod.rs b/packages/core/src/api/packages/modpack/mod.rs index 2e7ceb5d..1ce37c86 100644 --- a/packages/core/src/api/packages/modpack/mod.rs +++ b/packages/core/src/api/packages/modpack/mod.rs @@ -80,10 +80,12 @@ pub trait InstallableModpackFormatExt: Send + Sync + std::any::Any { ) -> LauncherResult<()>; fn as_any(self: Box) -> Box; + + fn kind(&self) -> ModpackFormat; } impl ModpackFormat { - pub async fn from_file(path: PathBuf) -> LauncherResult> { + 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); diff --git a/packages/core/src/api/packages/modpack/mrpack.rs b/packages/core/src/api/packages/modpack/mrpack.rs index 87df4265..ee559be0 100644 --- a/packages/core/src/api/packages/modpack/mrpack.rs +++ b/packages/core/src/api/packages/modpack/mrpack.rs @@ -140,6 +140,10 @@ impl InstallableModpackFormatExt for MrPackFormatImpl { self } + fn kind(&self) -> super::ModpackFormat { + super::ModpackFormat::MrPack + } + async fn manifest(&self) -> LauncherResult<&ModpackManifest> { if let Some(manifest) = self.manifest.get() { return Ok(manifest); @@ -210,6 +214,10 @@ pub(super) async fn download_and_link_packages( // TODO: Ingress for file in manifest.files.iter() { + if file.enabled == false { + continue; + } + match &file.kind { ModpackFileKind::Managed((package, version)) => { match api::packages::download_package(package, version, None, None).await { diff --git a/packages/core/src/api/packages/modpack/polymrpack.rs b/packages/core/src/api/packages/modpack/polymrpack.rs index 79c6e4c0..240aff94 100644 --- a/packages/core/src/api/packages/modpack/polymrpack.rs +++ b/packages/core/src/api/packages/modpack/polymrpack.rs @@ -137,6 +137,10 @@ impl InstallableModpackFormatExt for PolyMrPackFormatImpl { self } + fn kind(&self) -> super::ModpackFormat { + super::ModpackFormat::PolyMrPack + } + async fn manifest(&self) -> LauncherResult<&ModpackManifest> { if let Some(manifest) = self.manifest.get() { return Ok(manifest); @@ -301,8 +305,8 @@ pub(super) struct PolyMrPackManifest { pub id: String, pub category: String, pub enabled: bool, - #[serde(rename = "polyFormat")] - pub poly_format_version: i32, + // #[serde(rename = "polyFormat")] + // pub poly_format_version: i32, pub update_url: Option, pub format_version: usize, diff --git a/packages/core/src/api/packages/modpack/tests.rs b/packages/core/src/api/packages/modpack/tests.rs index f819bb43..b70e42fb 100644 --- a/packages/core/src/api/packages/modpack/tests.rs +++ b/packages/core/src/api/packages/modpack/tests.rs @@ -67,7 +67,7 @@ pub async fn test_install_modpack() -> LauncherResult<()> { ) .await?; - let modpack_format = ModpackFormat::from_file(modpack_model.path().await?).await?; + let modpack_format = ModpackFormat::from_file(&modpack_model.path().await?).await?; api::packages::modpack::install_managed_modpack( &modpack_model, &modpack_format, diff --git a/packages/core/src/utils/http.rs b/packages/core/src/utils/http.rs index b68d12f6..aaf7d58d 100644 --- a/packages/core/src/utils/http.rs +++ b/packages/core/src/utils/http.rs @@ -49,7 +49,16 @@ pub(crate) static REQWEST_CLIENT: LazyLock = LazyLock::new(|| { .expect("failed to build reqwest client!") }); -pub async fn request( +pub async fn request(method: Method, url: &str) -> LauncherResult { + request_builder( + method, + url, + None:: reqwest::Result<_>>, + ) + .await +} + +pub async fn request_builder( method: Method, url: &str, builder: Option reqwest::Result>, diff --git a/packages/core/src/utils/io.rs b/packages/core/src/utils/io.rs index f10e566c..64383c9a 100644 --- a/packages/core/src/utils/io.rs +++ b/packages/core/src/utils/io.rs @@ -1,3 +1,4 @@ +use std::fs::Metadata; use std::path::PathBuf; use std::sync::Arc; @@ -466,3 +467,7 @@ pub fn sanitize_path(path: impl AsRef) -> PathBuf { .map(sanitize_filename::sanitize) .collect() } + +pub async fn stat(path: impl AsRef) -> Result { + tokio::fs::metadata(path).await.map_err(IOError::from) +}