feat: multiple minecraft instances (#955)
* feat: mutliple minecraft instances (only dedicated clusters) * changeset
This commit is contained in:
parent
011c4416b1
commit
56d73e3ab1
20 changed files with 481 additions and 86 deletions
5
.changeset/multiple_minecraft_instances.md
Normal file
5
.changeset/multiple_minecraft_instances.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
default: minor
|
||||
---
|
||||
|
||||
# Added safe multiple minecraft instances launching, only dedicated clusters tho
|
||||
|
|
@ -13,7 +13,7 @@ use crate::hooks::{
|
|||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
use crate::utils::sort_clusters_for_home;
|
||||
use crate::view::app::{launch_button_state, launch_syncing};
|
||||
use crate::view::app::{launch_button_state, launch_syncing, shared_dir_taken};
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct ActiveClusterPanel;
|
||||
|
|
@ -92,6 +92,7 @@ impl Component for ActiveClusterPanel {
|
|||
};
|
||||
let cluster_id = cluster.id;
|
||||
let syncing = launch_syncing(&launcher, cluster.uses_bundles());
|
||||
let shared_dir_taken = shared_dir_taken(&game, &sorted, cluster_id);
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
|
|
@ -127,7 +128,7 @@ impl Component for ActiveClusterPanel {
|
|||
.child(launch_button(
|
||||
cluster_id,
|
||||
dispatch,
|
||||
launch_button_state(&game, cluster_id, syncing),
|
||||
launch_button_state(&game, cluster_id, syncing, shared_dir_taken),
|
||||
))
|
||||
.child(cluster_settings_button(cluster_id)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,12 +26,14 @@ impl Actions {
|
|||
.take()
|
||||
}
|
||||
|
||||
/// `shared` is every other cluster in the shared game dir when this one
|
||||
/// launches there too see [`crate::state::GameState::launch_block`]
|
||||
#[must_use]
|
||||
pub fn launch_block(&self, cluster_id: ClusterId) -> Option<LaunchBlock> {
|
||||
pub fn launch_block(&self, cluster_id: ClusterId, shared: &[ClusterId]) -> Option<LaunchBlock> {
|
||||
let station = self.station();
|
||||
let snapshot = station.peek();
|
||||
let parallel = snapshot.settings.settings.allow_parallel_running_clusters;
|
||||
snapshot.game.launch_block(cluster_id, parallel)
|
||||
snapshot.game.launch_block(cluster_id, parallel, shared)
|
||||
}
|
||||
|
||||
pub fn report_missing_shortcut_target(&self, folder: &str) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use crate::hooks::{
|
|||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
use crate::ui::entrance_motion_layer;
|
||||
use crate::view::app::{launch_button_state, launch_syncing};
|
||||
use crate::view::app::{launch_button_state, launch_syncing, use_shared_dir_taken};
|
||||
|
||||
const HEADER_HEIGHT: f32 = 64.;
|
||||
const TABS_HEIGHT: f32 = 38.;
|
||||
|
|
@ -113,13 +113,14 @@ impl Component for ClusterShell {
|
|||
let launcher = use_launcher();
|
||||
let cluster = use_cluster(cluster_id);
|
||||
let syncing = launch_syncing(&launcher, cluster.as_ref().is_none_or(|c| c.uses_bundles()));
|
||||
let shared_dir_taken = use_shared_dir_taken(cluster_id);
|
||||
|
||||
let show_game_log = game.is_active(cluster_id);
|
||||
let show_datapacks = cluster
|
||||
.as_ref()
|
||||
.is_none_or(|c| crate::view::app::cluster::supports_datapacks(&c.mc_version));
|
||||
let show_mod_tabs = cluster.as_ref().is_none_or(|c| !c.lacks_mod_loader());
|
||||
let launch_state = launch_button_state(&game, cluster_id, syncing);
|
||||
let launch_state = launch_button_state(&game, cluster_id, syncing, shared_dir_taken);
|
||||
|
||||
let hidden_tab = !show_mod_tabs
|
||||
&& matches!(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use freya::prelude::*;
|
||||
use freya::router::*;
|
||||
use oneclient_cluster::Cluster;
|
||||
use oneclient_core::LauncherState;
|
||||
|
||||
use crate::Actions;
|
||||
use crate::components::IconType;
|
||||
|
|
@ -65,7 +66,8 @@ async fn launch_shortcut(actions: Actions, router: RouterContext, folder: String
|
|||
cluster_id: cluster.id,
|
||||
});
|
||||
|
||||
let Some(block) = actions.launch_block(cluster.id) else {
|
||||
let shared = shared_dir_neighbours(&state, &cluster).await;
|
||||
let Some(block) = actions.launch_block(cluster.id, &shared) else {
|
||||
actions.launch_cluster(cluster.id);
|
||||
return;
|
||||
};
|
||||
|
|
@ -74,6 +76,27 @@ async fn launch_shortcut(actions: Actions, router: RouterContext, folder: String
|
|||
report_already_open(&actions, &cluster, block, blocker.as_deref());
|
||||
}
|
||||
|
||||
/// The other clusters that launch into the shared game dir empty when this
|
||||
/// cluster has a dedicated one
|
||||
async fn shared_dir_neighbours(state: &LauncherState, cluster: &Cluster) -> Vec<i64> {
|
||||
if cluster.uses_dedicated_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
match state.clusters.list().await {
|
||||
Ok(clusters) => clusters
|
||||
.into_iter()
|
||||
.filter(|other| other.id != cluster.id && !other.uses_dedicated_dir())
|
||||
.map(|other| other.id)
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
// Core refuses a busy shared dir anyway this only words the notice
|
||||
tracing::warn!(cluster_id = cluster.id, "cluster list failed: {err:#}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn blocker_name(cluster: &Cluster, block: LaunchBlock) -> Option<String> {
|
||||
if block.cluster_id() == cluster.id {
|
||||
return None;
|
||||
|
|
|
|||
|
|
@ -275,21 +275,25 @@ impl GameState {
|
|||
}
|
||||
}
|
||||
|
||||
/// `shared` lists the other clusters that would share the launch's game dir
|
||||
/// empty for a dedicated cluster with parallel clusters allowed only those
|
||||
/// can be in the way
|
||||
#[must_use]
|
||||
pub fn launch_block(&self, cluster_id: i64, parallel: bool) -> Option<LaunchBlock> {
|
||||
pub fn launch_block(
|
||||
&self,
|
||||
cluster_id: i64,
|
||||
parallel: bool,
|
||||
shared: &[i64],
|
||||
) -> Option<LaunchBlock> {
|
||||
if let Some(block) = self.block_for(cluster_id) {
|
||||
return Some(block);
|
||||
}
|
||||
|
||||
if parallel {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.stages
|
||||
.keys()
|
||||
.chain(self.pending.iter())
|
||||
.copied()
|
||||
.filter(|id| *id != cluster_id)
|
||||
.filter(|id| *id != cluster_id && (!parallel || shared.contains(id)))
|
||||
.find_map(|id| self.block_for(id))
|
||||
}
|
||||
|
||||
|
|
@ -366,9 +370,32 @@ mod tests {
|
|||
let mut game = GameState::default();
|
||||
game.stages.insert(1, LaunchStage::Running);
|
||||
|
||||
assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Running(1)));
|
||||
assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Running(1)));
|
||||
assert_eq!(game.launch_block(2, true), None);
|
||||
assert_eq!(
|
||||
game.launch_block(1, false, &[]),
|
||||
Some(LaunchBlock::Running(1))
|
||||
);
|
||||
assert_eq!(
|
||||
game.launch_block(2, false, &[]),
|
||||
Some(LaunchBlock::Running(1))
|
||||
);
|
||||
assert_eq!(game.launch_block(2, true, &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_clusters_still_hold_one_shared_game() {
|
||||
let mut game = GameState::default();
|
||||
game.stages.insert(1, LaunchStage::Running);
|
||||
|
||||
// 2 shares the game dir with 1 and 3 has its own
|
||||
assert_eq!(
|
||||
game.launch_block(2, true, &[1]),
|
||||
Some(LaunchBlock::Running(1))
|
||||
);
|
||||
assert_eq!(game.launch_block(3, true, &[]), None);
|
||||
assert_eq!(
|
||||
game.launch_block(1, true, &[]),
|
||||
Some(LaunchBlock::Running(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -376,8 +403,14 @@ mod tests {
|
|||
let mut game = GameState::default();
|
||||
game.stages.insert(1, LaunchStage::Downloading);
|
||||
|
||||
assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Starting(1)));
|
||||
assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Starting(1)));
|
||||
assert_eq!(
|
||||
game.launch_block(1, false, &[]),
|
||||
Some(LaunchBlock::Starting(1))
|
||||
);
|
||||
assert_eq!(
|
||||
game.launch_block(2, false, &[]),
|
||||
Some(LaunchBlock::Starting(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -385,8 +418,14 @@ mod tests {
|
|||
let mut game = GameState::default();
|
||||
game.begin_launch(1);
|
||||
|
||||
assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Starting(1)));
|
||||
assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Starting(1)));
|
||||
assert_eq!(
|
||||
game.launch_block(1, false, &[]),
|
||||
Some(LaunchBlock::Starting(1))
|
||||
);
|
||||
assert_eq!(
|
||||
game.launch_block(2, false, &[]),
|
||||
Some(LaunchBlock::Starting(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -394,23 +433,62 @@ mod tests {
|
|||
let mut game = GameState::default();
|
||||
game.stages.insert(1, LaunchStage::Exited);
|
||||
|
||||
assert_eq!(game.launch_block(2, false), None);
|
||||
assert_eq!(game.launch_block(1, false), None);
|
||||
assert_eq!(game.launch_block(2, false, &[]), None);
|
||||
assert_eq!(game.launch_block(1, false, &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_button_disables_on_the_claim_alone() {
|
||||
let mut game = GameState::default();
|
||||
assert_eq!(launch_button_state(&game, 1, false), ("Launch", true));
|
||||
assert_eq!(
|
||||
launch_button_state(&game, 1, false, false),
|
||||
("Launch", true)
|
||||
);
|
||||
|
||||
game.begin_launch(1);
|
||||
assert_eq!(launch_button_state(&game, 1, false), ("Launching", false));
|
||||
assert_eq!(
|
||||
launch_button_state(&game, 1, false, false),
|
||||
("Launching", false)
|
||||
);
|
||||
|
||||
// Held past a failure which parks the stage at `Exited`
|
||||
game.stages.insert(1, LaunchStage::Exited);
|
||||
assert_eq!(launch_button_state(&game, 1, false), ("Launching", false));
|
||||
assert_eq!(
|
||||
launch_button_state(&game, 1, false, false),
|
||||
("Launching", false)
|
||||
);
|
||||
|
||||
game.finish_launch(1);
|
||||
assert_eq!(launch_button_state(&game, 1, false), ("Launch", true));
|
||||
assert_eq!(
|
||||
launch_button_state(&game, 1, false, false),
|
||||
("Launch", true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_queued_launch_says_it_is_waiting() {
|
||||
let mut game = GameState::default();
|
||||
game.stages.insert(1, LaunchStage::Waiting);
|
||||
|
||||
assert_eq!(
|
||||
launch_button_state(&game, 1, false, false),
|
||||
("Waiting", false)
|
||||
);
|
||||
assert!(game.is_busy(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_taken_shared_dir_blocks_only_an_idle_cluster() {
|
||||
let mut game = GameState::default();
|
||||
assert_eq!(
|
||||
launch_button_state(&game, 1, false, true),
|
||||
("Folder in use", false)
|
||||
);
|
||||
|
||||
game.stages.insert(1, LaunchStage::Running);
|
||||
assert_eq!(
|
||||
launch_button_state(&game, 1, false, true),
|
||||
("Running", false)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ use crate::utils::{
|
|||
line_title, resolve_cluster, split_clusters,
|
||||
};
|
||||
use crate::view::app::clusters::CreateInstanceModal;
|
||||
use crate::view::app::{launch_button_state, launch_syncing};
|
||||
use crate::view::app::{launch_button_state, launch_syncing, use_shared_dir_taken};
|
||||
|
||||
const PAGE_PADDING: Gaps = Gaps::new(12., 28., 28., 28.);
|
||||
const COLUMN_GAP_PX: f32 = 24.;
|
||||
|
|
@ -723,6 +723,7 @@ impl Component for Sidebar {
|
|||
|
||||
let cluster_id = self.cluster_id;
|
||||
let syncing = launch_syncing(&launcher, self.uses_bundles);
|
||||
let shared_dir_taken = use_shared_dir_taken(cluster_id);
|
||||
let art_height = art_height_for(*sidebar_height.read());
|
||||
let top_padding = if art_height.is_some() { 4. } else { 22. };
|
||||
|
||||
|
|
@ -797,7 +798,7 @@ impl Component for Sidebar {
|
|||
.child(play_button(
|
||||
cluster_id,
|
||||
dispatch,
|
||||
launch_button_state(&game, cluster_id, syncing),
|
||||
launch_button_state(&game, cluster_id, syncing, shared_dir_taken),
|
||||
))
|
||||
.child(
|
||||
Button::new()
|
||||
|
|
|
|||
|
|
@ -9,21 +9,27 @@ pub mod browser;
|
|||
pub mod cluster;
|
||||
pub mod settings;
|
||||
|
||||
use crate::hooks::{settled_or_loading, use_clusters, use_game_snapshot};
|
||||
use crate::state::{GameState as GameSnapshot, LauncherInit};
|
||||
use oneclient_core::clusters::Cluster;
|
||||
use oneclient_events::LaunchStage;
|
||||
|
||||
pub fn launch_button_state(
|
||||
game: &GameSnapshot,
|
||||
cluster_id: i64,
|
||||
syncing: bool,
|
||||
shared_dir_taken: bool,
|
||||
) -> (&'static str, bool) {
|
||||
let state = match game.stage(cluster_id) {
|
||||
Some(LaunchStage::Checking) => ("Checking", false),
|
||||
Some(LaunchStage::Waiting) => ("Waiting", false),
|
||||
Some(LaunchStage::Downloading) => ("Downloading", false),
|
||||
Some(LaunchStage::Launching) => ("Launching", false),
|
||||
Some(LaunchStage::Running) => ("Running", false),
|
||||
// The click's own flag held for a beat after a failure so a burst of clicks starts one game
|
||||
_ if game.is_launch_pending(cluster_id) => ("Launching", false),
|
||||
// Core refuses it anyway the shared game dir holds one game at a time
|
||||
_ if shared_dir_taken => ("Folder in use", false),
|
||||
_ => ("Launch", true),
|
||||
};
|
||||
// Block launching while the startup bundle download is still running
|
||||
|
|
@ -33,6 +39,29 @@ pub fn launch_button_state(
|
|||
state
|
||||
}
|
||||
|
||||
/// Whether another shared cluster is starting or running in the shared game dir
|
||||
/// dedicated clusters never collide so any number of them may run alongside
|
||||
pub fn shared_dir_taken(game: &GameSnapshot, clusters: &[Cluster], cluster_id: i64) -> bool {
|
||||
// Active games are few so filter on them before touching the marker files
|
||||
let mut others = clusters
|
||||
.iter()
|
||||
.filter(|c| c.id != cluster_id && (game.is_active(c.id) || game.is_launch_pending(c.id)))
|
||||
.peekable();
|
||||
|
||||
others.peek().is_some()
|
||||
&& clusters
|
||||
.iter()
|
||||
.find(|c| c.id == cluster_id)
|
||||
.is_some_and(|c| !c.uses_dedicated_dir())
|
||||
&& others.any(|c| !c.uses_dedicated_dir())
|
||||
}
|
||||
|
||||
pub fn use_shared_dir_taken(cluster_id: i64) -> bool {
|
||||
let game = use_game_snapshot();
|
||||
let clusters = settled_or_loading(&use_clusters()).unwrap_or_default();
|
||||
shared_dir_taken(&game, &clusters, cluster_id)
|
||||
}
|
||||
|
||||
pub fn launch_syncing(launcher: &LauncherInit, uses_bundles: bool) -> bool {
|
||||
launcher.fetching || (uses_bundles && launcher.syncing_bundles)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,7 +143,16 @@ async fn game_download_bytes(services: &LauncherServices, info: &VersionInfo) ->
|
|||
let _ = services;
|
||||
// Runs before a Java runtime is resolved so rules are checked against the
|
||||
// host architecture only natives-related edge cases shift
|
||||
match game::plan_downloads(info, assets_index, std::env::consts::ARCH, false, false).await {
|
||||
match game::plan_downloads(
|
||||
info,
|
||||
assets_index,
|
||||
std::env::consts::ARCH,
|
||||
false,
|
||||
false,
|
||||
game::NativesMode::Extract,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(plan) => plan.total_bytes(),
|
||||
Err(err) => {
|
||||
tracing::warn!("download plan failed, estimating full size: {err}");
|
||||
|
|
@ -311,6 +320,8 @@ async fn install_cluster(
|
|||
.await?
|
||||
};
|
||||
|
||||
let natives_mode = crate::game::natives::natives_mode(state, &version_info.id, None);
|
||||
|
||||
download_minecraft(
|
||||
&state.services.mc(),
|
||||
progress,
|
||||
|
|
@ -318,6 +329,7 @@ async fn install_cluster(
|
|||
&java.os_arch,
|
||||
minecraft_updated,
|
||||
force,
|
||||
natives_mode,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,9 @@ pub async fn launch_cluster(
|
|||
) -> LauncherResult<LaunchedGame> {
|
||||
tracing::info!(cluster_id, search_for_java, "launching cluster");
|
||||
|
||||
let parallel = state.settings.read().allow_parallel_running_clusters;
|
||||
if !parallel && state.games.is_active(cluster_id) {
|
||||
// Parallel clusters or not a cluster runs only once a second game would
|
||||
// share its directory and its slot in the process manager
|
||||
if state.games.is_active(cluster_id) {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
"cluster already launching or running; refusing launch"
|
||||
|
|
@ -54,13 +55,9 @@ pub async fn launch_cluster(
|
|||
return Err(GameError::AlreadyRunning(cluster_id).into());
|
||||
}
|
||||
|
||||
// A parallel attempt shares its entry with the session already under way, so
|
||||
// clearing it here would drop that game's kill sender and pid
|
||||
let adopted = state.games.is_active(cluster_id);
|
||||
|
||||
let result = start(state, cluster_id, account, search_for_java).await;
|
||||
|
||||
if result.is_err() && !adopted {
|
||||
if result.is_err() {
|
||||
state.games.remove(cluster_id);
|
||||
state
|
||||
.services
|
||||
|
|
@ -83,7 +80,19 @@ async fn start(
|
|||
events.game_stage(cluster_id, s);
|
||||
};
|
||||
|
||||
// The stage goes first so a click while this waits is refused as already
|
||||
// active rather than queued behind it
|
||||
stage(LaunchStage::Checking);
|
||||
let launch_slot = match state.games.try_launch_slot() {
|
||||
Some(slot) => slot,
|
||||
None => {
|
||||
tracing::debug!(cluster_id, "another launch is preparing; waiting for it");
|
||||
stage(LaunchStage::Waiting);
|
||||
let slot = state.games.launch_slot().await;
|
||||
stage(LaunchStage::Checking);
|
||||
slot
|
||||
}
|
||||
};
|
||||
|
||||
let existing = state.clusters.get(cluster_id).await?;
|
||||
|
||||
|
|
@ -92,20 +101,15 @@ async fn start(
|
|||
|
||||
// Non-dedicated clusters share one directory so a second game there would
|
||||
// materialize over the running one's
|
||||
// `dir_in_use_by` waves a cluster past
|
||||
// its own session which parallel launches make reachable
|
||||
if !dedicated && let Some(other) = state.games.dir_in_use(&game_dir) {
|
||||
if let Err(other) = state.games.claim_dir(cluster_id, &game_dir, !dedicated) {
|
||||
if dedicated {
|
||||
return Err(GameError::DirectoryInUse(other).into());
|
||||
}
|
||||
tracing::warn!(cluster_id, other, "shared game dir busy; refusing launch");
|
||||
let name = running_cluster_name(state, other).await;
|
||||
return Err(GameError::SharedDirectoryBusy(name).into());
|
||||
}
|
||||
|
||||
if let Some(other) = state.games.dir_in_use_by(&game_dir, cluster_id) {
|
||||
return Err(GameError::DirectoryInUse(other).into());
|
||||
}
|
||||
|
||||
state.games.set_dir(cluster_id, game_dir.clone());
|
||||
|
||||
match state.clusters.mark_played(cluster_id).await {
|
||||
Ok(()) => events.signal(oneclient_events::Signal::ClustersChanged),
|
||||
Err(err) => tracing::debug!(cluster_id, error = %err, "could not record the launch time"),
|
||||
|
|
@ -221,6 +225,10 @@ async fn start(
|
|||
"resolved launch metadata"
|
||||
);
|
||||
|
||||
if let Some(dir) = crate::game::natives::natives_dir(&version_name) {
|
||||
state.games.set_natives(cluster_id, dir);
|
||||
}
|
||||
|
||||
let java = if let Some(runtime) = state
|
||||
.java
|
||||
.runtime_for_profile(profile.java_path.as_deref())
|
||||
|
|
@ -248,6 +256,8 @@ async fn start(
|
|||
.set_stage(cluster_id, ClusterStage::Repairing)
|
||||
.await;
|
||||
stage(LaunchStage::Downloading);
|
||||
let natives_mode =
|
||||
crate::game::natives::natives_mode(state, &version_name, Some(cluster_id));
|
||||
if let Err(err) = download_minecraft(
|
||||
&state.services.mc(),
|
||||
&progress,
|
||||
|
|
@ -255,6 +265,7 @@ async fn start(
|
|||
&java.os_arch,
|
||||
updated,
|
||||
false,
|
||||
natives_mode,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -366,6 +377,8 @@ async fn start(
|
|||
profile.game_args.as_deref(),
|
||||
);
|
||||
|
||||
drop(launch_slot);
|
||||
|
||||
if let Some(reason) = run_hook(profile.hook_pre.as_deref(), &cwd).await {
|
||||
events
|
||||
.notify("Pre-launch command failed")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ pub mod gpu;
|
|||
mod heal;
|
||||
mod launch;
|
||||
mod log_replay;
|
||||
pub(crate) mod natives;
|
||||
mod process;
|
||||
mod reattach;
|
||||
mod session;
|
||||
|
|
@ -28,7 +29,8 @@ pub use fabric::{mods_folder_argument, uses_cluster_mods_folder};
|
|||
pub use heal::clear_zeroed_files;
|
||||
pub use launch::{LaunchedGame, is_running, launch_cluster, offer_repair};
|
||||
pub use oneclient_mc::{
|
||||
DownloadPlan, GameVersionInfo, GameVersionKind, NativesReport, PlannedLibrary, check_natives,
|
||||
DownloadPlan, GameVersionInfo, GameVersionKind, NativesMode, NativesReport, PlannedLibrary,
|
||||
check_natives,
|
||||
confirm_incomplete_install, download_minecraft, download_version_info, game_files_missing,
|
||||
get_game_versions, get_loader_version, get_loader_versions, get_loaders_for_version,
|
||||
get_version_ids, get_versions_for_loader, is_version_updated, libraries_missing,
|
||||
|
|
|
|||
58
packages/oneclient_core/src/game/natives.rs
Normal file
58
packages/oneclient_core/src/game/natives.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use oneclient_common::domain::GameLoader;
|
||||
use oneclient_common::paths;
|
||||
use oneclient_mc::MetadataStore;
|
||||
|
||||
use crate::clusters::Cluster;
|
||||
use crate::game::NativesMode;
|
||||
use crate::state::LauncherState;
|
||||
|
||||
pub(crate) fn natives_dir(version_name: &str) -> Option<PathBuf> {
|
||||
paths::natives_dir().ok().map(|dir| dir.join(version_name))
|
||||
}
|
||||
|
||||
pub(crate) fn cached_version_name(metadata: &MetadataStore, cluster: &Cluster) -> Option<String> {
|
||||
let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version);
|
||||
|
||||
if cluster.mc_loader == GameLoader::Vanilla {
|
||||
return Some(mc_version);
|
||||
}
|
||||
|
||||
let loader = oneclient_mc::get_loader_version_cached(
|
||||
metadata,
|
||||
&mc_version,
|
||||
cluster.mc_loader,
|
||||
cluster.mc_loader_version.as_deref(),
|
||||
)
|
||||
.ok()??;
|
||||
|
||||
Some(format!("{mc_version}-{}", loader.id))
|
||||
}
|
||||
|
||||
pub(crate) fn natives_holder(
|
||||
state: &LauncherState,
|
||||
version_name: &str,
|
||||
exclude: Option<i64>,
|
||||
) -> Option<i64> {
|
||||
let dir = natives_dir(version_name)?;
|
||||
state.games.natives_in_use_by(&dir, exclude)
|
||||
}
|
||||
|
||||
pub(crate) fn natives_mode(
|
||||
state: &LauncherState,
|
||||
version_name: &str,
|
||||
exclude: Option<i64>,
|
||||
) -> NativesMode {
|
||||
match natives_holder(state, version_name, exclude) {
|
||||
Some(holder) => {
|
||||
tracing::info!(
|
||||
version_name,
|
||||
holder,
|
||||
"natives are loaded by a running game; leaving them in place"
|
||||
);
|
||||
NativesMode::LeaveInPlace
|
||||
}
|
||||
None => NativesMode::Extract,
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +63,8 @@ pub struct GameProcessManager {
|
|||
inner: Mutex<HashMap<i64, GameProcess>>,
|
||||
kills: Mutex<HashMap<i64, oneshot::Sender<()>>>,
|
||||
dirs: Mutex<HashMap<i64, PathBuf>>,
|
||||
natives: Mutex<HashMap<i64, PathBuf>>,
|
||||
launching: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
impl GameProcessManager {
|
||||
|
|
@ -70,6 +72,18 @@ impl GameProcessManager {
|
|||
Self::default()
|
||||
}
|
||||
|
||||
/// One launch prepares at a time two launches at once would install the
|
||||
/// same Java or download the same game files into the same paths
|
||||
/// Held from the checks until the process spawns not for the whole session
|
||||
pub async fn launch_slot(&self) -> tokio::sync::MutexGuard<'_, ()> {
|
||||
self.launching.lock().await
|
||||
}
|
||||
|
||||
/// `None` while another launch holds the slot
|
||||
pub fn try_launch_slot(&self) -> Option<tokio::sync::MutexGuard<'_, ()>> {
|
||||
self.launching.try_lock().ok()
|
||||
}
|
||||
|
||||
pub fn register_kill(&self, cluster_id: i64, tx: oneshot::Sender<()>) {
|
||||
self.kills.lock().unwrap().insert(cluster_id, tx);
|
||||
}
|
||||
|
|
@ -110,21 +124,44 @@ impl GameProcessManager {
|
|||
self.inner.lock().unwrap().remove(&cluster_id);
|
||||
self.kills.lock().unwrap().remove(&cluster_id);
|
||||
self.dirs.lock().unwrap().remove(&cluster_id);
|
||||
self.natives.lock().unwrap().remove(&cluster_id);
|
||||
}
|
||||
|
||||
pub fn set_natives(&self, cluster_id: i64, dir: PathBuf) {
|
||||
self.natives.lock().unwrap().insert(cluster_id, dir);
|
||||
}
|
||||
|
||||
pub fn natives_in_use_by(&self, dir: &Path, exclude: Option<i64>) -> Option<i64> {
|
||||
self.natives
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|(id, d)| Some(**id) != exclude && d.as_path() == dir)
|
||||
.map(|(id, _)| *id)
|
||||
}
|
||||
|
||||
pub fn set_dir(&self, cluster_id: i64, dir: PathBuf) {
|
||||
self.dirs.lock().unwrap().insert(cluster_id, dir);
|
||||
}
|
||||
|
||||
/// Like [`Self::dir_in_use_by`] but counts a cluster's own session too the
|
||||
/// shared game directory holds one game at a time whoever it belongs to
|
||||
pub fn dir_in_use(&self, dir: &Path) -> Option<i64> {
|
||||
self.dirs
|
||||
.lock()
|
||||
.unwrap()
|
||||
/// Check and record under one lock so two launches racing on separate
|
||||
/// threads cannot both find the directory free
|
||||
/// `exclusive` is for the shared game directory which holds one game at a
|
||||
/// time whoever it belongs to counting the cluster's own session too
|
||||
/// Returns the cluster already holding `dir`
|
||||
pub fn claim_dir(&self, cluster_id: i64, dir: &Path, exclusive: bool) -> Result<(), i64> {
|
||||
let mut dirs = self.dirs.lock().unwrap();
|
||||
|
||||
let holder = dirs
|
||||
.iter()
|
||||
.find(|(_, d)| d.as_path() == dir)
|
||||
.map(|(id, _)| *id)
|
||||
.find(|(id, d)| (exclusive || **id != cluster_id) && d.as_path() == dir)
|
||||
.map(|(id, _)| *id);
|
||||
if let Some(holder) = holder {
|
||||
return Err(holder);
|
||||
}
|
||||
|
||||
dirs.insert(cluster_id, dir.to_path_buf());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn dir_in_use_by(&self, dir: &Path, exclude: i64) -> Option<i64> {
|
||||
|
|
@ -177,3 +214,43 @@ impl GameProcessManager {
|
|||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_shared_dir_takes_one_game() {
|
||||
let games = GameProcessManager::new();
|
||||
let shared = Path::new("shared");
|
||||
|
||||
assert_eq!(games.claim_dir(1, shared, true), Ok(()));
|
||||
assert_eq!(games.claim_dir(2, shared, true), Err(1));
|
||||
|
||||
games.remove(1);
|
||||
assert_eq!(games.claim_dir(2, shared, true), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedicated_dirs_do_not_get_in_each_others_way() {
|
||||
let games = GameProcessManager::new();
|
||||
|
||||
assert_eq!(games.claim_dir(1, Path::new("one"), false), Ok(()));
|
||||
assert_eq!(games.claim_dir(2, Path::new("two"), false), Ok(()));
|
||||
assert_eq!(games.claim_dir(3, Path::new("one"), false), Err(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natives_are_held_until_the_game_is_removed() {
|
||||
let games = GameProcessManager::new();
|
||||
let natives = Path::new("natives/1.8.9");
|
||||
|
||||
games.set_natives(1, natives.to_path_buf());
|
||||
assert_eq!(games.natives_in_use_by(natives, None), Some(1));
|
||||
assert_eq!(games.natives_in_use_by(natives, Some(1)), None);
|
||||
assert_eq!(games.natives_in_use_by(Path::new("natives/1.21"), None), None);
|
||||
|
||||
games.remove(1);
|
||||
assert_eq!(games.natives_in_use_by(natives, None), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,6 +155,15 @@ async fn readopt(
|
|||
state.games.set_stage(cluster_id, LaunchStage::Running);
|
||||
state.games.set_pid(cluster_id, Some(pid));
|
||||
state.games.set_dir(cluster_id, cwd.clone());
|
||||
let version_name = state
|
||||
.metadata
|
||||
.try_lock()
|
||||
.ok()
|
||||
.and_then(|metadata| crate::game::natives::cached_version_name(&metadata, &cluster));
|
||||
match version_name.as_deref().and_then(crate::game::natives::natives_dir) {
|
||||
Some(dir) => state.games.set_natives(cluster_id, dir),
|
||||
None => tracing::debug!(cluster_id, "could not resolve the re-adopted game's natives"),
|
||||
}
|
||||
state
|
||||
.services
|
||||
.events
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ pub struct ViewState {
|
|||
pub sort: Option<String>,
|
||||
}
|
||||
|
||||
pub const CURRENT_SETTINGS_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(default)]
|
||||
pub struct LauncherSettings {
|
||||
|
|
@ -93,10 +95,22 @@ impl LauncherSettings {
|
|||
}
|
||||
}
|
||||
|
||||
impl LauncherSettings {
|
||||
/// Brings a file written by an older launcher up to the current defaults
|
||||
pub fn migrate(&mut self) {
|
||||
if self.settings_version < 2 {
|
||||
// Had no toggle so a saved `false` was never the user's choice
|
||||
// dedicated clusters plus one shared cluster may now run together
|
||||
self.allow_parallel_running_clusters = true;
|
||||
}
|
||||
self.settings_version = CURRENT_SETTINGS_VERSION;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LauncherSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
settings_version: 1,
|
||||
settings_version: CURRENT_SETTINGS_VERSION,
|
||||
log_debug: false,
|
||||
auto_update: true,
|
||||
crash_reporting: true,
|
||||
|
|
@ -104,7 +118,7 @@ impl Default for LauncherSettings {
|
|||
enable_gamemode: false,
|
||||
max_concurrent_requests: 25,
|
||||
global_game_settings: GameSettingsProfile::default_global_profile(),
|
||||
allow_parallel_running_clusters: false,
|
||||
allow_parallel_running_clusters: true,
|
||||
launch_behaviour: LaunchBehaviour::default(),
|
||||
run_in_background: false,
|
||||
show_tray_icon: true,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ pub async fn load_settings(notify: Option<&EventBus>) -> LauncherSettings {
|
|||
LauncherSettings::default()
|
||||
} else {
|
||||
let data = polyio::read(&path).await?;
|
||||
serde_json::from_slice(&data)?
|
||||
let mut settings: LauncherSettings = serde_json::from_slice(&data)?;
|
||||
settings.migrate();
|
||||
settings
|
||||
})
|
||||
}
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -156,25 +156,11 @@ async fn natives_in_use(state: &LauncherState) -> Option<HashSet<String>> {
|
|||
|
||||
let mut in_use = HashSet::new();
|
||||
for cluster in clusters {
|
||||
let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version);
|
||||
|
||||
if cluster.mc_loader == oneclient_common::domain::GameLoader::Vanilla {
|
||||
in_use.insert(mc_version);
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved = oneclient_mc::get_loader_version_cached(
|
||||
&metadata,
|
||||
&mc_version,
|
||||
cluster.mc_loader,
|
||||
cluster.mc_loader_version.as_deref(),
|
||||
);
|
||||
|
||||
match resolved {
|
||||
Ok(Some(loader)) => {
|
||||
in_use.insert(format!("{mc_version}-{}", loader.id));
|
||||
match crate::game::natives::cached_version_name(&metadata, &cluster) {
|
||||
Some(version_name) => {
|
||||
in_use.insert(version_name);
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
None => {
|
||||
tracing::info!(
|
||||
cluster = %cluster.name,
|
||||
loader = %cluster.mc_loader,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ pub struct ClusterVerifyReport {
|
|||
pub unrepairable: Vec<String>,
|
||||
/// Game files re-downloaded uncounted because the assets index was gone too
|
||||
pub reinstalled_game_files: bool,
|
||||
pub natives_in_use_by: Option<String>,
|
||||
}
|
||||
|
||||
impl ClusterVerifyReport {
|
||||
|
|
@ -38,6 +39,16 @@ impl ClusterVerifyReport {
|
|||
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
let summary = self.counts_summary();
|
||||
match &self.natives_in_use_by {
|
||||
Some(name) => format!(
|
||||
"{summary} Native libraries were skipped because {name} is running, verify again after closing it."
|
||||
),
|
||||
None => summary,
|
||||
}
|
||||
}
|
||||
|
||||
fn counts_summary(&self) -> String {
|
||||
if self.is_clean() {
|
||||
return format!("All {} files verified.", self.checked);
|
||||
}
|
||||
|
|
@ -155,6 +166,7 @@ async fn run_verify(
|
|||
// Counted apart from content which repairs itself as it goes one bucket
|
||||
// would count the content ones twice
|
||||
let mut game_broken = 0;
|
||||
let mut natives_broken = 0;
|
||||
|
||||
// The index lives in the assets directory so its absence usually means that
|
||||
// whole directory is gone hand the work to prepare
|
||||
|
|
@ -174,6 +186,7 @@ async fn run_verify(
|
|||
report.corrupt += game.corrupt;
|
||||
report.missing += game.missing;
|
||||
game_broken = game.corrupt + game.missing;
|
||||
natives_broken = game.natives_unextracted;
|
||||
} else {
|
||||
tracing::warn!("assets index is missing; re-downloading the game files wholesale");
|
||||
}
|
||||
|
|
@ -182,9 +195,26 @@ async fn run_verify(
|
|||
|
||||
// Runs `prepare` directly not the launch path which skips clusters already
|
||||
// marked `Ready` and so never heals one with deleted files
|
||||
if game_broken > 0 || reinstalled_game_files {
|
||||
let natives_holder = crate::game::natives::natives_holder(state, &version_info.id, None)
|
||||
.filter(|_| natives_broken > 0);
|
||||
let repairable = if natives_holder.is_some() {
|
||||
game_broken.saturating_sub(natives_broken)
|
||||
} else {
|
||||
game_broken
|
||||
};
|
||||
|
||||
if repairable > 0 || reinstalled_game_files {
|
||||
prepare_cluster_locked(state, cluster_id, false, true, true, Some(progress)).await?;
|
||||
report.repaired += game_broken;
|
||||
report.repaired += repairable;
|
||||
}
|
||||
|
||||
if let Some(holder) = natives_holder {
|
||||
let name = state
|
||||
.clusters
|
||||
.get(holder)
|
||||
.await
|
||||
.map_or_else(|_| "another cluster".to_string(), |cluster| cluster.name);
|
||||
report.natives_in_use_by = Some(name);
|
||||
}
|
||||
|
||||
report.reinstalled_game_files = reinstalled_game_files;
|
||||
|
|
@ -358,6 +388,7 @@ mod tests {
|
|||
repaired: 4,
|
||||
unrepairable: Vec::new(),
|
||||
reinstalled_game_files: false,
|
||||
natives_in_use_by: None,
|
||||
};
|
||||
|
||||
assert!(!report.is_clean());
|
||||
|
|
@ -382,6 +413,20 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_natives_are_named_rather_than_counted_as_repaired() {
|
||||
let report = ClusterVerifyReport {
|
||||
checked: 40,
|
||||
natives_in_use_by: Some("Hypixel".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
report.summary(),
|
||||
"All 40 files verified. Native libraries were skipped because Hypixel is running, verify again after closing it."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_that_cannot_be_replaced_is_still_reported() {
|
||||
let report = ClusterVerifyReport {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ pub enum GameEvent {
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LaunchStage {
|
||||
Checking,
|
||||
/// Queued behind another cluster's launch
|
||||
Waiting,
|
||||
Downloading,
|
||||
Launching,
|
||||
Running,
|
||||
|
|
@ -118,7 +120,10 @@ pub enum LaunchStage {
|
|||
impl LaunchStage {
|
||||
#[must_use]
|
||||
pub fn is_busy(self) -> bool {
|
||||
matches!(self, Self::Checking | Self::Downloading | Self::Launching)
|
||||
matches!(
|
||||
self,
|
||||
Self::Checking | Self::Waiting | Self::Downloading | Self::Launching
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ const ASSET_DOWNLOAD_CONCURRENCY: usize = 32;
|
|||
/// Libraries are larger and fewer less fan-out is needed to saturate the link
|
||||
const LIBRARY_DOWNLOAD_CONCURRENCY: usize = 16;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NativesMode {
|
||||
Extract,
|
||||
LeaveInPlace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlannedLibrary {
|
||||
pub library: Library,
|
||||
|
|
@ -167,6 +173,7 @@ pub async fn plan_downloads(
|
|||
java_arch: &str,
|
||||
minecraft_updated: bool,
|
||||
force: bool,
|
||||
natives_mode: NativesMode,
|
||||
) -> McResult<DownloadPlan> {
|
||||
let legacy = uses_legacy_assets(&version.assets);
|
||||
let asset_dir = if legacy {
|
||||
|
|
@ -227,7 +234,12 @@ pub async fn plan_downloads(
|
|||
&lib_dir.join(&artifact_path),
|
||||
library_artifact_size(&lib),
|
||||
));
|
||||
let natives = force || !natives_extracted(&natives_dir, &lib, &java_arch);
|
||||
let natives = match natives_mode {
|
||||
NativesMode::Extract => {
|
||||
force || !natives_extracted(&natives_dir, &lib, &java_arch)
|
||||
}
|
||||
NativesMode::LeaveInPlace => false,
|
||||
};
|
||||
|
||||
if !artifact && !natives {
|
||||
continue;
|
||||
|
|
@ -269,6 +281,7 @@ pub struct VerifyReport {
|
|||
/// Mismatched the manifest and were removed
|
||||
pub corrupt: usize,
|
||||
pub missing: usize,
|
||||
pub natives_unextracted: usize,
|
||||
}
|
||||
|
||||
impl VerifyReport {
|
||||
|
|
@ -293,6 +306,7 @@ fn arch_segment(java_arch: &str) -> &'static str {
|
|||
pub struct NativesReport {
|
||||
pub repairable: Vec<String>,
|
||||
pub unsupported: Vec<String>,
|
||||
pub unextracted: Vec<String>,
|
||||
}
|
||||
|
||||
impl NativesReport {
|
||||
|
|
@ -377,9 +391,10 @@ pub async fn check_natives(
|
|||
// One scan for the whole sweep the directory is the same for every library
|
||||
let unpacked = any_native_unpacked(&natives_dir).await;
|
||||
|
||||
let mut families: Vec<(String, Vec<JarVerdict>)> = Vec::new();
|
||||
let mut families: Vec<(String, Vec<(JarVerdict, bool)>)> = Vec::new();
|
||||
for lib in host_natives {
|
||||
let verdict = if native_download(lib, java_arch).is_some() {
|
||||
let extracted = native_download(lib, java_arch).is_some();
|
||||
let verdict = if extracted {
|
||||
if unpacked && natives_extracted(&natives_dir, lib, java_arch) {
|
||||
JarVerdict::Usable
|
||||
} else {
|
||||
|
|
@ -394,17 +409,23 @@ pub async fn check_natives(
|
|||
|
||||
let family = natives_family(lib);
|
||||
match families.iter_mut().find(|(name, _)| name == &family) {
|
||||
Some((_, verdicts)) => verdicts.push(verdict),
|
||||
None => families.push((family, vec![verdict])),
|
||||
Some((_, verdicts)) => verdicts.push((verdict, extracted)),
|
||||
None => families.push((family, vec![(verdict, extracted)])),
|
||||
}
|
||||
}
|
||||
|
||||
let mut report = NativesReport::default();
|
||||
for (family, verdicts) in families {
|
||||
if verdicts.iter().any(|v| matches!(v, JarVerdict::Usable)) {
|
||||
if verdicts.iter().any(|(v, _)| matches!(v, JarVerdict::Usable)) {
|
||||
continue;
|
||||
}
|
||||
if verdicts.iter().any(|v| matches!(v, JarVerdict::Unreadable)) {
|
||||
if verdicts.iter().any(|(v, _)| matches!(v, JarVerdict::Unreadable)) {
|
||||
if verdicts
|
||||
.iter()
|
||||
.any(|(v, extracted)| *extracted && matches!(v, JarVerdict::Unreadable))
|
||||
{
|
||||
report.unextracted.push(family.clone());
|
||||
}
|
||||
report.repairable.push(family);
|
||||
} else {
|
||||
report.unsupported.push(family);
|
||||
|
|
@ -554,6 +575,7 @@ pub async fn verify_game_files(
|
|||
Err(err) => return Err(err),
|
||||
};
|
||||
report.missing += natives.repairable.len();
|
||||
report.natives_unextracted = natives.unextracted.len();
|
||||
report.checked += natives.repairable.len() + natives.unsupported.len();
|
||||
|
||||
tracing::info!(
|
||||
|
|
@ -577,16 +599,26 @@ pub async fn download_minecraft(
|
|||
java_arch: &str,
|
||||
minecraft_updated: bool,
|
||||
force: bool,
|
||||
natives_mode: NativesMode,
|
||||
) -> McResult<()> {
|
||||
let started = std::time::Instant::now();
|
||||
let asset_index = download_assets_index(ctx, progress, version, force).await?;
|
||||
let plan = plan_downloads(version, asset_index, java_arch, minecraft_updated, force).await?;
|
||||
let plan = plan_downloads(
|
||||
version,
|
||||
asset_index,
|
||||
java_arch,
|
||||
minecraft_updated,
|
||||
force,
|
||||
natives_mode,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
version = %version.id,
|
||||
assets = plan.assets.len(),
|
||||
libraries = plan.libraries.len(),
|
||||
bytes = plan.total_bytes(),
|
||||
?natives_mode,
|
||||
"planned minecraft download"
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue