OneLauncher-Cracked/packages/oneclient_app/src/install.rs
Maciek 0ab544f265
feat: custom cluster instances (#859)
* feat: added adding new instances of a cluster with 'start from scratch' option enabling user to create a vanilla/modded cluster; fixed couple pre-existing bugs that wouldn't let other loaders boot up

* feat: fully implemented cluster creation modal design

* chore: refactoring

* feat: expanded the tags section on customization step; found that fabric doesnt work with 1.8.9

* feat: added gallery covers handling for new cluster instances

* feat: added 'redirecting' from fabric to orinthe on versions pre-1.14

* feat: added java-version-override

* feat: added loader types migrations (right now they dont migrate mods because there are no bundles)

* chore: wider loader dropdown for multiloader cluster (if it will be used in the future)

* chore: fixed more issues with isolated and shared dir, with migrations and instances naming

* chore: refactor; taken the loader version dropdown from version picker view back to loader selector

* chore: deleted Quilt from UI

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-29 19:08:21 +02:00

529 lines
16 KiB
Rust

use std::sync::Arc;
use oneclient_core::LauncherState;
use oneclient_content::packages::PackageStore;
use oneclient_events::Level;
use crate::components::IconType;
use crate::notifications::{
ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind,
NotificationSpec, OptionalModsGroup, PackageUpdateGroup,
};
/// Falls back to a placeholder a missing row must not lose the whole message
pub async fn cluster_display_name(
cluster_id: i64,
services: &oneclient_core::LauncherServices,
) -> String {
PackageStore::get_cluster(cluster_id, &services.content())
.await
.map(|cluster| cluster.name)
.unwrap_or_else(|_| "Cluster".to_string())
}
pub async fn package_update_group(
cluster_id: i64,
updates: &[oneclient_core::BrowserPackageUpdate],
services: &oneclient_core::LauncherServices,
) -> Option<PackageUpdateGroup> {
if updates.is_empty() {
return None;
}
Some(PackageUpdateGroup {
cluster_id,
cluster_name: cluster_display_name(cluster_id, services).await,
packages: updates.to_vec(),
})
}
pub fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdateItem {
match &file.kind {
oneclient_core::BundleFileKind::Managed {
provider,
project_id,
..
} => ClusterUpdateItem {
provider: *provider,
github_hosted: false,
project_id: Some(project_id.clone()),
fallback: file.display_name(),
offer: None,
status: None,
},
oneclient_core::BundleFileKind::External { .. } => ClusterUpdateItem {
provider: oneclient_content::packages::ProviderId::Local,
github_hosted: file.is_github_hosted(),
project_id: Some(file.kind.metadata_id()),
fallback: file.display_name(),
offer: None,
status: None,
},
}
}
fn item_from_optional(package: &oneclient_core::BundleOptionalPackage) -> ClusterUpdateItem {
ClusterUpdateItem {
offer: Some((package.bundle_name.clone(), package.package_id.clone())),
..item_from_bundle_file(&package.file)
}
}
async fn cluster_update_summary(
cluster_id: i64,
result: &oneclient_core::ApplyBundleUpdatesResult,
services: &oneclient_core::LauncherServices,
) -> Option<ClusterUpdateSummary> {
let updated: Vec<ClusterUpdateItem> = result
.updates_applied
.iter()
.map(|u| item_from_bundle_file(&u.new_file))
.collect();
let added: Vec<ClusterUpdateItem> = result
.additions_applied
.iter()
.map(|a| item_from_bundle_file(&a.new_file))
.collect();
let removed: Vec<ClusterUpdateItem> = result
.removals_applied
.iter()
.map(|r| ClusterUpdateItem {
provider: r
.provider
.unwrap_or(oneclient_content::packages::ProviderId::Local),
github_hosted: false,
project_id: r.project_id.clone(),
fallback: r
.display_name
.clone()
.unwrap_or_else(|| r.package_id.clone()),
offer: None,
status: None,
})
.collect();
let optional: Vec<ClusterUpdateItem> = result
.optional_available
.iter()
.map(item_from_optional)
.collect();
if updated.is_empty() && added.is_empty() && removed.is_empty() && optional.is_empty() {
return None;
}
let cluster_name =
oneclient_content::packages::PackageStore::get_cluster(cluster_id, &services.content())
.await
.map(|c| c.name)
.unwrap_or_else(|_| "Cluster".to_string());
Some(ClusterUpdateSummary {
cluster_id,
cluster_name,
updated,
added,
removed,
optional,
})
}
pub async fn cluster_update_notification(
cluster_id: i64,
result: &oneclient_core::ApplyBundleUpdatesResult,
services: &oneclient_core::LauncherServices,
) -> Option<NotificationSpec> {
let summary = cluster_update_summary(cluster_id, result, services).await?;
let total = summary.total();
// A bundle can offer an opt-in mod without changing anything else
// "0 packages changed" would be both wrong and alarming
let (title, body) = if total == 0 {
let offers = summary.optional.len();
(
"Optional mods available",
format!(
"{offers} optional mod{} in {}",
if offers == 1 { "" } else { "s" },
summary.cluster_name
),
)
} else {
(
"Cluster updated",
format!(
"{total} package{} changed in {}",
if total == 1 { "" } else { "s" },
summary.cluster_name
),
)
};
Some(NotificationSpec {
title: title.to_string(),
body,
level: Level::Info,
icon: Some(IconType::DownloadCloud02),
progress: None,
actions: vec![NotificationAction {
label: "View changes".to_string(),
kind: NotificationActionKind::OpenClusterUpdate(vec![summary]),
}],
toast_only: false,
})
}
pub async fn pending_optional_group(
cluster_id: i64,
pending: &[oneclient_core::PendingOptionalMod],
services: &oneclient_core::LauncherServices,
) -> Option<OptionalModsGroup> {
if pending.is_empty() {
return None;
}
let mods: Vec<ClusterUpdateItem> = pending
.iter()
.map(|entry| ClusterUpdateItem {
offer: Some((entry.bundle_name.clone(), entry.package_id.clone())),
status: Some(entry.status),
..item_from_bundle_file(&entry.file)
})
.collect();
Some(OptionalModsGroup {
cluster_id,
cluster_name: cluster_display_name(cluster_id, services).await,
mods,
})
}
pub struct PackageInstall {
pub session_id: Option<uuid::Uuid>,
pub result: anyhow::Result<String>,
pub dependencies: Vec<String>,
pub missing_dependencies: Vec<String>,
/// The game is open but could not take the package, so the toast must not
/// claim it is there now
pub live_deferred: bool,
}
pub struct FlaggedInstall {
pub name: String,
pub mc_version: String,
pub explanation: Option<String>,
pub alternatives: Vec<oneclient_content::packages::ResolvedAlternative>,
}
pub fn install_body(
name: &str,
dependencies: &[String],
missing: &[String],
live_deferred: bool,
) -> String {
let mut body = format!("Added {name}");
if !dependencies.is_empty() {
body.push_str(&format!(
" with {} dependenc{}",
dependencies.len(),
if dependencies.len() == 1 { "y" } else { "ies" }
));
}
body.push('.');
if !missing.is_empty() {
body.push_str(&format!(" Could not add: {}.", missing.join(", ")));
}
if live_deferred {
body.push_str(" Minecraft is running, so it will be there at the next launch.");
}
body
}
impl PackageInstall {
fn failed(err: anyhow::Error) -> Self {
Self {
session_id: None,
result: Err(err),
dependencies: Vec::new(),
missing_dependencies: Vec::new(),
live_deferred: false,
}
}
}
async fn flagged_install(
name: String,
entry: &oneclient_content::packages::BadMod,
cluster_id: i64,
content: &oneclient_content::ContentCtx,
) -> FlaggedInstall {
let resolve = async {
match PackageStore::get_cluster(cluster_id, content).await {
Ok(cluster) => {
let alternatives =
oneclient_content::packages::resolve_alternatives(entry, &cluster, content)
.await;
(cluster.mc_version, alternatives)
}
Err(err) => {
tracing::warn!(%err, cluster_id, "cannot resolve alternatives without the cluster");
(String::new(), Vec::new())
}
}
};
let ((mc_version, alternatives), explanation) = tokio::join!(
resolve,
oneclient_content::packages::fetch_explanation(entry, content),
);
FlaggedInstall {
name,
mc_version,
explanation,
alternatives,
}
}
pub async fn install_package(
state: &Arc<LauncherState>,
provider: oneclient_content::packages::ProviderId,
project_id: &str,
version_id: &str,
cluster_id: i64,
world: Option<String>,
allow_flagged: bool,
) -> Result<PackageInstall, FlaggedInstall> {
let lookup = async {
let provider_impl = state.services.packages.get(provider)?;
let project = provider_impl
.get_project(project_id, &state.services.content())
.await?;
let version = provider_impl
.get_version(project_id, version_id, &state.services.content())
.await?;
anyhow::Ok((project, version))
}
.await;
let (project, version) = match lookup {
Ok(found) => found,
Err(err) => return Ok(PackageInstall::failed(err)),
};
if let Some(world) = world {
let project = oneclient_content::packages::types::ProjectDetail {
content_type: oneclient_content::packages::ContentType::DataPack,
..project
};
return Ok(install_datapack(state, provider, &project, &version, cluster_id, world).await);
}
let content = state.services.content();
let bad_mods = oneclient_content::packages::load_bad_mods(&content).await;
let screened = !allow_flagged
&& !state
.clusters
.get(cluster_id)
.await
.is_ok_and(|cluster| cluster.user_created);
if screened && let Some(entry) = bad_mods.check(&project, &version) {
tracing::warn!(project = %project.name, version = %version.version_number, "refusing to install flagged mod");
return Err(flagged_install(project.name, entry, cluster_id, &content).await);
}
// Resolved before the session starts so its children can be announced up front
let mut resolution = oneclient_content::packages::DependencyResolution::default();
if oneclient_content::packages::resolves_dependencies(project.content_type) {
match oneclient_content::packages::resolve_required(
provider,
&version,
cluster_id,
&state.services.content(),
)
.await
{
Ok(resolved) => resolution = resolved,
Err(err) => {
tracing::warn!(%err, "dependency resolution failed, installing package alone");
}
}
}
if screened
&& let Some((dependency, entry)) = resolution.install.iter().find_map(|dependency| {
bad_mods
.check(&dependency.project, &dependency.version)
.map(|entry| (dependency, entry))
})
{
tracing::warn!(dependency = %dependency.project.name, project = %project.name, "refusing to install flagged dependency");
let name = format!("{} (required by {})", dependency.project.name, project.name);
return Err(flagged_install(name, entry, cluster_id, &content).await);
}
let session = oneclient_events::GroupedProgressSession::start(
&state.services.events,
format!("Installing {}", project.name),
);
let size = version.primary_file().map(|f| f.size).unwrap_or(0);
let dependency_bytes: u64 = resolution
.install
.iter()
.map(|dep| dep.version.primary_file().map(|f| f.size).unwrap_or(0))
.sum();
session.expect(
oneclient_events::TaskCategory::Packages,
1 + resolution.install.len() as u64,
size + dependency_bytes,
);
let mut installed_dependencies = Vec::new();
let mut missing_dependencies = resolution.unresolved;
// Dependencies first so the package is never in a cluster without them however the run ends
for dependency in &resolution.install {
let child = session.child(
dependency.project.name.clone(),
dependency
.version
.primary_file()
.map(|f| f.size)
.unwrap_or(0),
oneclient_events::TaskCategory::Packages,
);
let result = PackageStore::install_to_cluster(
provider,
&dependency.project,
&dependency.version,
cluster_id,
false,
false,
Some(&child),
&state.services.content(),
)
.await;
child.finish();
match result {
Ok((artifact, _)) => {
mark_new(cluster_id, &artifact.hash, state).await;
installed_dependencies.push(dependency.project.name.clone());
}
Err(err) => {
tracing::warn!(
dependency = %dependency.project.name,
%err,
"failed to install dependency"
);
missing_dependencies.push(dependency.project.name.clone());
}
}
}
let child = session.child(
project.name.clone(),
size,
oneclient_events::TaskCategory::Packages,
);
let result = PackageStore::install_to_cluster(
provider,
&project,
&version,
cluster_id,
false,
false,
Some(&child),
&state.services.content(),
)
.await;
child.finish();
let live_deferred = matches!(
result,
Ok((_, oneclient_content::packages::LiveSync::Deferred))
) && state.games.is_active(cluster_id);
if let Ok((artifact, _)) = &result {
mark_new(cluster_id, &artifact.hash, state).await;
}
Ok(PackageInstall {
session_id: Some(session.detach()),
result: result.map(|_| project.name).map_err(anyhow::Error::from),
dependencies: installed_dependencies,
missing_dependencies,
live_deferred,
})
}
async fn install_datapack(
state: &Arc<LauncherState>,
provider: oneclient_content::packages::ProviderId,
project: &oneclient_content::packages::types::ProjectDetail,
version: &oneclient_content::packages::types::VersionDetail,
cluster_id: i64,
world: String,
) -> PackageInstall {
let Some(file) = version.primary_file() else {
return PackageInstall::failed(anyhow::anyhow!("This version has no file to download"));
};
if !file.file_name.to_lowercase().ends_with(".zip") {
return PackageInstall::failed(anyhow::anyhow!(
"This version is a mod, not a data pack. Pick a .zip version instead"
));
}
let session = oneclient_events::GroupedProgressSession::start(
&state.services.events,
format!("Installing {}", project.name),
);
session.expect(oneclient_events::TaskCategory::Packages, 1, file.size);
let child = session.child(
project.name.clone(),
file.size,
oneclient_events::TaskCategory::Packages,
);
let result = async {
let artifact = PackageStore::download_and_cache(
provider,
project,
version,
false,
Some(&child),
&state.services.content(),
)
.await?;
let path = oneclient_content::packages::store::artifact_absolute_path(&artifact.path)?;
let cluster = state.clusters.get(cluster_id).await?;
oneclient_core::add_world_datapacks(&cluster, &world, &[path]).await?;
anyhow::Ok(())
}
.await;
child.finish();
PackageInstall {
session_id: Some(session.detach()),
result: result.map(|()| format!("{} to {world}", project.name)),
dependencies: Vec::new(),
missing_dependencies: Vec::new(),
live_deferred: false,
}
}
async fn mark_new(cluster_id: i64, hash: &str, state: &LauncherState) {
if let Err(err) =
PackageStore::mark_artifact_new(cluster_id, hash, &state.services.content()).await
{
tracing::warn!(cluster_id, hash, %err, "failed to badge package as new");
}
}