feat: oneclient bundles

This commit is contained in:
LynithDev 2025-09-21 17:08:03 +02:00
parent 0a77a31846
commit 1eb6505c0c
12 changed files with 167 additions and 61 deletions

View file

@ -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<HashMap<u32, Vec<clusters::Model>>>;
#[taurpc(alias = "getModpacksFor")]
async fn get_modpacks_for(cluster_id: ClusterId) -> LauncherResult<Vec<ModpackArchive>>;
#[taurpc(alias = "getBundlesFor")]
async fn get_bundles_for(cluster_id: ClusterId) -> LauncherResult<Vec<ModpackArchive>>;
}
#[taurpc::ipc_type]
@ -54,26 +55,21 @@ impl OneClientApi for OneClientApiImpl {
Ok(mapped)
}
async fn get_modpacks_for(self, cluster_id: ClusterId) -> LauncherResult<Vec<ModpackArchive>> {
struct ModpackEntry {
pub category: String,
pub version: String,
pub loader: GameLoader,
}
async fn get_bundles_for(self, cluster_id: ClusterId) -> LauncherResult<Vec<ModpackArchive>> {
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)
}
}

View file

@ -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";

View file

@ -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<Vec<ModpackArchive>> {
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::<BundleManifest>(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<Box<dyn InstallableModpackFormatExt>> {
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::<u64>().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?)
}

View file

@ -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}");
}
}

View file

@ -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<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
@ -282,14 +282,14 @@ getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise<Manag
installModpack: (modpack: ModpackArchive, clusterId: number) => Promise<null>,
fetchMinecraftProfile: (uuid: string) => Promise<MojangPlayerProfile>,
open: (input: string) => Promise<null> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'oneclient': { openDevTools: () => Promise<void>,
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
getModpacksFor: (clusterId: number) => Promise<ModpackArchive[]> } };
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> } };
export type { InferCommandOutput }

View file

@ -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() {
<h1 className="text-2xl font-semibold">Cluster Overview</h1>
<p>Welcome to the cluster overview page. Here you can find information about your clusters.</p>
<div style={{ height: '1500px' }}>
Cluster Name: {cluster.name}
</div>
<p>test</p>
{/* Additional content can be added here */}

View file

@ -80,10 +80,12 @@ pub trait InstallableModpackFormatExt: Send + Sync + std::any::Any {
) -> LauncherResult<()>;
fn as_any(self: Box<Self>) -> Box<dyn std::any::Any + Send + Sync>;
fn kind(&self) -> ModpackFormat;
}
impl ModpackFormat {
pub async fn from_file(path: PathBuf) -> LauncherResult<Box<dyn InstallableModpackFormatExt>> {
pub async fn from_file(path: &PathBuf) -> LauncherResult<Box<dyn InstallableModpackFormatExt>> {
for stage in FORMAT_PIPELINE_PATH {
if let Some(format) = stage(path.clone()).await? {
return Ok(format);

View file

@ -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 {

View file

@ -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<String>,
pub format_version: usize,

View file

@ -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,

View file

@ -49,7 +49,16 @@ pub(crate) static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
.expect("failed to build reqwest client!")
});
pub async fn request(
pub async fn request(method: Method, url: &str) -> LauncherResult<reqwest::Response> {
request_builder(
method,
url,
None::<fn(reqwest::RequestBuilder) -> reqwest::Result<_>>,
)
.await
}
pub async fn request_builder(
method: Method,
url: &str,
builder: Option<impl Fn(reqwest::RequestBuilder) -> reqwest::Result<reqwest::Request>>,

View file

@ -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<std::path::Path>) -> PathBuf {
.map(sanitize_filename::sanitize)
.collect()
}
pub async fn stat(path: impl AsRef<std::path::Path>) -> Result<Metadata, IOError> {
tokio::fs::metadata(path).await.map_err(IOError::from)
}