OneLauncher-Cracked/packages/oneclient_app/src/utils.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

520 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::cmp::Ordering;
use std::collections::BTreeMap;
use chrono::{Datelike, NaiveDate};
use oneclient_common::domain::GameLoader;
use oneclient_common::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version};
use oneclient_core::BundleArchive;
use oneclient_core::clusters::Cluster;
use oneclient_common::MEMORY_HEADROOM_GB;
pub use oneclient_common::total_ram_mb;
pub type ClusterGroups = BTreeMap<ReleaseLine, Vec<Cluster>>;
/// The modern scheme puts a full release in the first two components (`26.1`) so each
/// minor is its own line legacy `1.x` versions keep the whole major (`1.21`) as one line
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ReleaseLine {
pub major: u32,
/// `Some` only for the modern scheme where the minor is part of the line
pub minor: Option<u32>,
}
impl ReleaseLine {
fn from_parsed(parsed: &ParsedMcVersion) -> Option<Self> {
let minor = if parsed.major >= 26 {
Some(parsed.minor?)
} else {
None
};
Some(Self {
major: parsed.major,
minor,
})
}
pub fn from_version(version: &str) -> Option<Self> {
Self::from_parsed(&parse_mc_version(version)?)
}
pub fn for_cluster(cluster: &Cluster) -> Option<Self> {
Self::from_version(&cluster.mc_version)
}
/// The generic name of the line `26.1` `1.21`
pub fn pretty_name(&self) -> String {
match self.minor {
Some(minor) => format!("{}.{minor}", self.major),
None => format!("1.{}", self.major),
}
}
fn art_key(&self) -> Option<VersionKey> {
self.minor.map(|minor| (minor, None))
}
}
fn cluster_key(cluster: &Cluster) -> Option<VersionKey> {
parse_mc_version(&cluster.mc_version).and_then(|parsed| parsed.key())
}
fn sole_version_key(clusters: &[Cluster]) -> Option<VersionKey> {
match version_keys(clusters).as_slice() {
[only] => Some(*only),
_ => None,
}
}
/// A line with a single version is shown as that full version (`26.1.2`) else `1.21`
pub fn line_title(line: ReleaseLine, clusters: &[Cluster]) -> String {
match sole_version_key(clusters) {
Some(key) => version_label(line.major, key),
None => line.pretty_name(),
}
}
pub fn line_art_key(line: ReleaseLine, clusters: &[Cluster]) -> Option<VersionKey> {
sole_version_key(clusters).or_else(|| line.art_key())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GridSelection {
Line(ReleaseLine),
Instance(i64),
}
pub fn split_clusters(clusters: &[Cluster]) -> (ClusterGroups, Vec<Cluster>) {
let (instances, provisioned): (Vec<&Cluster>, Vec<&Cluster>) =
clusters.iter().partition(|cluster| cluster.user_created);
(
group_clusters_by_release(provisioned),
sort_clusters_for_home(instances.into_iter().cloned().collect()),
)
}
pub fn group_clusters_by_release<'a>(
clusters: impl IntoIterator<Item = &'a Cluster>,
) -> ClusterGroups {
let mut groups: ClusterGroups = BTreeMap::new();
for cluster in clusters {
let Some(line) = ReleaseLine::for_cluster(cluster) else {
continue;
};
groups.entry(line).or_default().push(cluster.clone());
}
for list in groups.values_mut() {
list.sort_by_key(|b| std::cmp::Reverse(cluster_key(b)));
}
groups
}
pub fn loader_tags(clusters: &[Cluster]) -> Vec<String> {
let mut tags = Vec::new();
for cluster in clusters {
if cluster.mc_loader.is_modded() {
let label = cluster.mc_loader.to_string();
if !tags.iter().any(|t| t == &label) {
tags.push(label);
}
}
}
tags.sort();
tags
}
pub fn version_keys(clusters: &[Cluster]) -> Vec<VersionKey> {
let mut keys = Vec::new();
for cluster in clusters {
if let Some(key) = cluster_key(cluster)
&& !keys.contains(&key)
{
keys.push(key);
}
}
keys.sort();
keys
}
pub fn loaders_for_line(clusters: &[Cluster]) -> Vec<GameLoader> {
let mut loaders = Vec::new();
for cluster in clusters {
if cluster.mc_loader.is_modded() && !loaders.contains(&cluster.mc_loader) {
loaders.push(cluster.mc_loader);
}
}
loaders.sort_by_key(|loader| loader.to_string());
loaders
}
pub fn resolve_cluster(
clusters: &[Cluster],
key: Option<VersionKey>,
loader: Option<GameLoader>,
) -> Option<Cluster> {
clusters
.iter()
.find(|cluster| {
let version_ok = key.is_none_or(|key| cluster_key(cluster) == Some(key));
let loader_ok = loader.is_none_or(|loader| cluster.mc_loader == loader);
version_ok && loader_ok
})
.cloned()
}
/// The line the page opens on the active cluster's line else the newest
pub fn default_line(groups: &ClusterGroups, active: Option<Cluster>) -> Option<ReleaseLine> {
if let Some(cluster) = active
&& let Some(line) = ReleaseLine::for_cluster(&cluster)
{
return Some(line);
}
groups.keys().next_back().copied()
}
pub fn default_version_key(
clusters: &[Cluster],
preferred: Option<VersionKey>,
) -> Option<VersionKey> {
let keys = version_keys(clusters);
if keys.is_empty() {
return None;
}
if let Some(key) = preferred
&& keys.contains(&key)
{
return Some(key);
}
keys.last().copied()
}
pub fn default_loader(clusters: &[Cluster], preferred: Option<GameLoader>) -> Option<GameLoader> {
let loaders = loaders_for_line(clusters);
if loaders.is_empty() {
return clusters.first().map(|c| c.mc_loader);
}
if let Some(loader) = preferred
&& loaders.contains(&loader)
{
return Some(loader);
}
Some(loaders[0])
}
pub fn version_label(major: u32, (minor, patch): VersionKey) -> String {
format_mc_version(major, minor, patch)
}
pub fn format_size(bytes: u64) -> String {
const KB: f64 = 1024.;
const MB: f64 = KB * 1024.;
let b = bytes as f64;
if b >= MB {
format!("{:.1} MB", b / MB)
} else if b >= KB {
format!("{:.0} KB", b / KB)
} else {
format!("{bytes} B")
}
}
pub fn format_res((w, h): (u32, u32)) -> String {
format!("{w}×{h}")
}
/// Prevents user from choosing his max amount of ram preset (e.g Someone has 16GB of RAM,
/// so the max preset is 16GB - 2GB = 14GB)
const MEMORY_PRESETS_GB: [u32; 11] = [2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64];
pub fn memory_presets_mb() -> Vec<u32> {
presets_for_total_gb((total_ram_mb() as f32 / 1024.).round() as u32)
}
fn presets_for_total_gb(total_gb: u32) -> Vec<u32> {
let usable_gb = total_gb.saturating_sub(MEMORY_HEADROOM_GB);
if usable_gb == 0 {
return vec![1024];
}
let mut presets: Vec<u32> = MEMORY_PRESETS_GB
.iter()
.copied()
.filter(|gb| *gb <= usable_gb)
.map(|gb| gb * 1024)
.collect();
let usable_mb = usable_gb * 1024;
if presets.last() != Some(&usable_mb) {
presets.push(usable_mb);
}
presets
}
/// `8192` -> `8 GB` `1536` -> `1.5 GB`
pub fn format_memory_gb(mb: u32) -> String {
if mb.is_multiple_of(1024) {
format!("{} GB", mb / 1024)
} else {
format!("{:.1} GB", mb as f32 / 1024.)
}
}
/// `45` -> `45s` `540` -> `9m` `7200` -> `2h` `7384` -> `2h 3m` `180000` -> `2d 2h`
pub fn format_duration(secs: i64) -> String {
if secs <= 0 {
return "0m".to_string();
}
let days = secs / 86_400;
let hours = (secs % 86_400) / 3600;
let minutes = (secs % 3600) / 60;
match (days, hours, minutes) {
(0, 0, 0) => format!("{secs}s"),
(0, 0, m) => format!("{m}m"),
(0, h, 0) => format!("{h}h"),
(0, h, m) => format!("{h}h {m}m"),
(d, 0, _) => format!("{d}d"),
(d, h, _) => format!("{d}d {h}h"),
}
}
/// `3723` -> `1h 2m` `83` -> `1m 23s` `45` -> `45s`
pub fn format_durations(secs: i64) -> String {
if secs <= 0 {
return "0s".to_string();
}
if secs >= 3600 {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
} else if secs >= 60 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{secs}s")
}
}
/// `1500` -> `1.5K` `2_400_000` -> `2.4M`
pub fn abbreviate_number(n: u64) -> String {
let f = n as f64;
if f >= 1_000_000.0 {
format!("{:.1}M", f / 1_000_000.0)
} else if f >= 1_000.0 {
format!("{:.1}K", f / 1_000.0)
} else {
n.to_string()
}
}
pub fn plural(n: i64) -> &'static str {
if n == 1 { "" } else { "s" }
}
pub fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
/// 24-hour clock label `9` -> `09:00`
pub fn format_hour(hour: usize) -> String {
format!("{hour:02}:00")
}
pub fn parse_day(date: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()
}
/// `2026-07-05` -> `Jul 5`
pub fn format_day(date: NaiveDate) -> String {
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
format!("{} {}", MONTHS[date.month0() as usize], date.day())
}
pub fn bundle_display_name(archive: &BundleArchive) -> String {
let category = archive.manifest.category.trim();
if category.is_empty() {
archive.manifest.name.clone()
} else {
category.to_string()
}
}
pub fn sort_clusters_for_home(mut clusters: Vec<Cluster>) -> Vec<Cluster> {
clusters.sort_by(compare_last_played);
clusters
}
fn compare_last_played(a: &Cluster, b: &Cluster) -> Ordering {
match (a.last_played, b.last_played) {
// Most recently played first
(Some(a), Some(b)) => b.cmp(&a),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
// Never played latest version first (major then minor)
(None, None) => version_sort_key(&b.mc_version).cmp(&version_sort_key(&a.mc_version)),
}
}
pub fn version_sort_key(version: &str) -> (u32, u32, u32) {
parse_mc_version(version)
.map(|v| (v.major, v.minor.unwrap_or(0), v.patch.unwrap_or(0)))
.unwrap_or((0, 0, 0))
}
#[cfg(not(target_os = "linux"))]
pub fn is_wayland() -> bool {
false
}
#[cfg(target_os = "linux")]
pub fn is_wayland() -> bool {
static IS_WAYLAND: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*IS_WAYLAND.get_or_init(|| {
if cfg!(target_os = "linux") {
std::env::var("XDG_SESSION_TYPE")
.map(|v| v == "wayland")
.unwrap_or(false)
} else {
false
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::view::onboarding::test_support::cluster;
fn versioned(id: i64, mc_version: &str) -> Cluster {
Cluster {
mc_version: mc_version.to_string(),
..cluster(id)
}
}
fn line(major: u32, minor: Option<u32>) -> ReleaseLine {
ReleaseLine { major, minor }
}
fn lines_of(clusters: &[Cluster]) -> Vec<ReleaseLine> {
group_clusters_by_release(clusters)
.keys()
.copied()
.collect()
}
#[test]
fn modern_minors_are_separate_lines() {
let clusters = [
versioned(1, "26.1"),
versioned(2, "26.2"),
versioned(3, "26.2.1"),
];
assert_eq!(
lines_of(&clusters),
vec![line(26, Some(1)), line(26, Some(2))]
);
}
#[test]
fn legacy_minors_share_one_line() {
let clusters = [versioned(1, "1.21.5"), versioned(2, "1.21.11")];
assert_eq!(lines_of(&clusters), vec![line(21, None)]);
}
#[test]
fn a_lone_version_is_shown_in_full() {
let clusters = [versioned(1, "26.1.2")];
let line = line(26, Some(1));
assert_eq!(line_title(line, &clusters), "26.1.2");
assert_eq!(line_art_key(line, &clusters), Some((1, Some(2))));
}
#[test]
fn several_loaders_of_one_version_still_count_as_one() {
let clusters = [
versioned(1, "26.1.2"),
Cluster {
mc_loader: GameLoader::Vanilla,
..versioned(2, "26.1.2")
},
];
assert_eq!(line_title(line(26, Some(1)), &clusters), "26.1.2");
}
#[test]
fn several_versions_fall_back_to_the_generic_name() {
let legacy = [
versioned(1, "1.21.1"),
versioned(2, "1.21.10"),
versioned(3, "1.21.11"),
];
assert_eq!(line_title(line(21, None), &legacy), "1.21");
assert_eq!(line_art_key(line(21, None), &legacy), None);
let modern = [versioned(4, "26.1"), versioned(5, "26.1.2")];
assert_eq!(line_title(line(26, Some(1)), &modern), "26.1");
assert_eq!(line_art_key(line(26, Some(1)), &modern), Some((1, None)));
}
#[test]
fn presets_stop_two_gigabytes_short_of_the_machine() {
assert_eq!(
presets_for_total_gb(16),
vec![2048, 3072, 4096, 6144, 8192, 12288, 14336]
);
assert_eq!(
presets_for_total_gb(32),
vec![2048, 3072, 4096, 6144, 8192, 12288, 16384, 24576, 30720]
);
}
#[test]
fn a_ceiling_landing_on_a_round_step_is_not_repeated() {
assert_eq!(presets_for_total_gb(8), vec![2048, 3072, 4096, 6144]);
}
#[test]
fn a_tiny_machine_still_gets_one_preset() {
assert_eq!(presets_for_total_gb(4), vec![2048]);
assert_eq!(presets_for_total_gb(2), vec![1024]);
}
#[test]
fn memory_labels_drop_the_decimal_when_whole() {
assert_eq!(format_memory_gb(8192), "8 GB");
assert_eq!(format_memory_gb(1536), "1.5 GB");
}
#[test]
fn durations_never_show_a_zero_unit() {
assert_eq!(format_duration(45), "45s");
assert_eq!(format_duration(540), "9m");
assert_eq!(format_duration(7200), "2h");
assert_eq!(format_duration(7384), "2h 3m");
assert_eq!(format_duration(180_000), "2d 2h");
assert_eq!(format_duration(172_800), "2d");
assert_eq!(format_duration(0), "0m");
}
#[test]
fn a_bare_legacy_version_keeps_its_line_name() {
let clusters = [versioned(1, "1.21")];
assert_eq!(line_title(line(21, None), &clusters), "1.21");
}
}